[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Separate out Library component

Kasper (Mar 5, 2026, 1:28 PM +0100) 99f3f801 1b9e6223

+407 -224
+25 -224
mobile/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import commands from '$lib/commands' 2 + import commands from '$lib/commands' 3 3 import { open } from '@tauri-apps/plugin-dialog'; 4 - import type { Track, TrackList, Playlist, LibraryTauri } from '../../bindings' 4 + import type { LibraryTauri } from '../../bindings' 5 5 import { readTextFile } from '@tauri-apps/plugin-fs' 6 + import Library from './Library.svelte' 6 7 7 - type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount'; 8 - type sort_dir_type = 'asc' | 'desc'; 9 - type active_filter_type = { kind: 'all' } | { kind: 'liked' } | { kind: 'genre'; value: string }; 10 8 11 - let library: LibraryTauri | null = null; 12 - let loading = false; 13 - let error = ''; 9 + let library = $state<LibraryTauri | null>(null); 10 + let loading = $state(false); 11 + let error = $state(''); 14 12 15 - let search_query = ''; 16 - let sort_key: sort_key_type = 'name'; 17 - let sort_dir: sort_dir_type = 'asc'; 18 - let active_filter: active_filter_type = { kind: 'all' }; 19 - let selected_playlist_id: string | null = null; 13 + // ── Library loading ──────────────────────────────────────────────────────── 20 14 21 15 async function open_library() { 22 16 const path = await open({ ··· 25 19 if (!path) return; 26 20 27 21 loading = true; 22 + library = null 28 23 error = ''; 29 24 try { 30 25 const contents = await readTextFile(path); 31 26 const result = await commands.loadLibrary(contents); 32 27 if (result.status === 'ok') { 33 28 library = result.data; 34 - selected_playlist_id = null; 35 - active_filter = { kind: 'all' }; 36 29 } else { 37 30 error = result.error; 38 31 } ··· 42 35 loading = false; 43 36 } 44 37 } 45 - 46 - function toggle_sort(key: sort_key_type) { 47 - if (sort_key === key) { 48 - sort_dir = sort_dir === 'asc' ? 'desc' : 'asc'; 49 - } else { 50 - sort_key = key; 51 - sort_dir = 'asc'; 52 - } 53 - } 54 - 55 - function sort_indicator(key: sort_key_type): string { 56 - if (sort_key !== key) return ''; 57 - return sort_dir === 'asc' ? ' ↑' : ' ↓'; 58 - } 59 - 60 - function format_duration(seconds: number): string { 61 - const s = Math.floor(seconds); 62 - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; 63 - } 64 - 65 - // All tracks as an array, preserving insertion order 66 - $: all_tracks = Object.values(library?.tracks ?? {}).filter((t): t is Track => t !== null); 67 - 68 - // Playlists and folders from track_lists (exclude special) 69 - $: track_lists = Object.values(library?.track_lists ?? {}).filter((tl): tl is TrackList => tl !== null); 70 - $: playlists = track_lists.filter((tl): tl is Playlist & { type: 'playlist' } => tl.type === 'playlist'); 71 - 72 - // Tracks for the selected playlist, resolved from item IDs (indices into all_tracks) 73 - $: playlist_tracks = (() => { 74 - if (selected_playlist_id === null) return all_tracks; 75 - const playlist = playlists.find(p => p.id === selected_playlist_id); 76 - if (!playlist) return all_tracks; 77 - return playlist.tracks 78 - .map(item_id => all_tracks[item_id]) 79 - .filter((t): t is Track => t !== null); 80 - })(); 81 - 82 - $: genres = [...new Set( 83 - all_tracks.map(t => t.genre).filter((g): g is string => g !== null) 84 - )].sort(); 85 - 86 - $: filtered_tracks = playlist_tracks 87 - .filter(t => { 88 - if (active_filter.kind === 'liked') return t.liked === true; 89 - if (active_filter.kind === 'genre') return t.genre === active_filter.value; 90 - return true; 91 - }) 92 - .filter(t => { 93 - if (!search_query) return true; 94 - const q = search_query.toLowerCase(); 95 - return ( 96 - t.name.toLowerCase().includes(q) || 97 - (t.artist?.toLowerCase().includes(q) ?? false) || 98 - (t.albumName?.toLowerCase().includes(q) ?? false) 99 - ); 100 - }) 101 - .sort((a, b) => { 102 - let av: string | number; 103 - let bv: string | number; 104 - if (sort_key === 'name') { av = a.name; bv = b.name; } 105 - else if (sort_key === 'artist') { av = a.artist ?? ''; bv = b.artist ?? ''; } 106 - else if (sort_key === 'dateAdded') { av = a.dateAdded; bv = b.dateAdded; } 107 - else { av = a.playCount ?? 0; bv = b.playCount ?? 0; } 108 - if (av < bv) return sort_dir === 'asc' ? -1 : 1; 109 - if (av > bv) return sort_dir === 'asc' ? 1 : -1; 110 - return 0; 111 - }); 112 38 </script> 113 39 114 40 <div class="flex flex-col h-screen bg-neutral-950 text-neutral-200 text-sm overflow-hidden"> 115 - 116 - <!-- Header --> 117 - <header class="flex items-center gap-3 px-4 py-3 border-b border-neutral-800 shrink-0"> 118 - <div class="relative flex-1"> 119 - <span class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none text-xs select-none">⌕</span> 120 - <input 121 - type="search" 122 - placeholder="Search tracks, artists, albums…" 123 - bind:value={search_query} 124 - class="w-full pl-8 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 text-sm placeholder-neutral-600 outline-none focus:border-neutral-500 transition-colors" 125 - /> 126 - </div> 127 - <button 128 - type="button" 129 - onclick={open_library} 130 - disabled={loading} 131 - class="shrink-0 px-4 py-2 bg-neutral-100 text-neutral-900 rounded-lg font-semibold text-sm hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 132 - > 133 - {loading ? 'Loading…' : 'Open'} 134 - </button> 135 - </header> 136 - 137 41 {#if error} 138 - <div class="px-4 py-2.5 bg-red-950 border-b border-red-900 text-red-400 text-xs shrink-0"> 139 - ⚠ {error} 140 - </div> 42 + <div class="px-4 py-2.5 bg-red-950 border-b border-red-900 text-red-400 text-xs shrink-0">⚠ {error}</div> 141 43 {/if} 142 44 143 - {#if library} 144 - <!-- Playlists row --> 145 - {#if playlists.length > 0} 146 - <div class="flex items-center gap-2 px-4 py-2.5 border-b border-neutral-800 overflow-x-auto no-scrollbar shrink-0"> 147 - <button 148 - type="button" 149 - onclick={() => selected_playlist_id = null} 150 - class="shrink-0 px-3 py-1 rounded-full text-xs border transition-colors {selected_playlist_id === null ? 'bg-neutral-200 border-neutral-200 text-neutral-900 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}" 151 - > 152 - All tracks 153 - </button> 154 - {#each playlists as playlist} 155 - <button 156 - type="button" 157 - onclick={() => selected_playlist_id = playlist.id} 158 - class="shrink-0 px-3 py-1 rounded-full text-xs border transition-colors {selected_playlist_id === playlist.id ? 'bg-neutral-200 border-neutral-200 text-neutral-900 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}" 159 - > 160 - {playlist.name} 161 - </button> 162 - {/each} 163 - </div> 164 - {/if} 165 - 166 - <!-- Sort + filter row --> 167 - <div class="shrink-0 border-b border-neutral-800"> 168 - <div class="flex items-center gap-1.5 px-4 py-2 overflow-x-auto no-scrollbar"> 169 - <span class="text-xs text-neutral-600 shrink-0 mr-0.5">Sort:</span> 170 - <button type="button" onclick={() => toggle_sort('name')} 171 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'name' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 172 - Name{sort_indicator('name')} 173 - </button> 174 - <button type="button" onclick={() => toggle_sort('artist')} 175 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'artist' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 176 - Artist{sort_indicator('artist')} 177 - </button> 178 - <button type="button" onclick={() => toggle_sort('dateAdded')} 179 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'dateAdded' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 180 - Date{sort_indicator('dateAdded')} 181 - </button> 182 - <button type="button" onclick={() => toggle_sort('playCount')} 183 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'playCount' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 184 - Plays{sort_indicator('playCount')} 185 - </button> 186 - <span class="ml-auto shrink-0 text-xs text-neutral-600 tabular-nums pl-2"> 187 - {filtered_tracks.length}/{playlist_tracks.length} 188 - </span> 189 - </div> 190 - 191 - <div class="flex items-center gap-1.5 px-4 pb-2 overflow-x-auto no-scrollbar"> 192 - <span class="text-xs text-neutral-600 shrink-0 mr-0.5">Filter:</span> 193 - <button type="button" onclick={() => active_filter = { kind: 'all' }} 194 - class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'all' ? 'bg-neutral-200 border-neutral-200 text-neutral-900 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 195 - All 196 - </button> 197 - <button type="button" onclick={() => active_filter = { kind: 'liked' }} 198 - class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'liked' ? 'bg-rose-900 border-rose-700 text-rose-200 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 199 - ♥ Liked 200 - </button> 201 - {#each genres as genre} 202 - <button type="button" onclick={() => active_filter = { kind: 'genre', value: genre }} 203 - class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'genre' && active_filter.value === genre ? 'bg-neutral-700 border-neutral-600 text-neutral-100 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 204 - {genre} 205 - </button> 206 - {/each} 207 - </div> 208 - </div> 45 + {#snippet open_button()} 46 + <button 47 + type="button" 48 + onclick={open_library} 49 + disabled={loading} 50 + class="shrink-0 px-3 py-1.5 bg-neutral-100 text-neutral-900 rounded-lg font-semibold text-xs hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 51 + > 52 + {loading ? 'Loading…' : 'Open'} 53 + </button> 54 + {/snippet} 209 55 210 - <!-- Track list --> 211 - <div class="flex-1 overflow-y-auto"> 212 - {#if filtered_tracks.length > 0} 213 - <ul class="divide-y divide-neutral-900"> 214 - {#each filtered_tracks as track} 215 - <li class="flex items-center gap-3 px-4 py-3 hover:bg-neutral-800/50 active:bg-neutral-800 transition-colors"> 216 - <div class="flex-1 min-w-0"> 217 - <p class="font-medium text-neutral-100 truncate">{track.name}</p> 218 - <p class="text-xs text-neutral-500 truncate mt-0.5"> 219 - {track.artist ?? 'Unknown Artist'} 220 - {#if track.albumName} 221 - <span class="text-neutral-700"> · </span>{track.albumName} 222 - {/if} 223 - </p> 224 - <div class="flex items-center gap-2 mt-1"> 225 - {#if track.genre} 226 - <span class="px-1.5 py-px text-xs rounded bg-neutral-800 text-neutral-500">{track.genre}</span> 227 - {/if} 228 - {#if track.year} 229 - <span class="text-xs text-neutral-700">{track.year}</span> 230 - {/if} 231 - </div> 232 - </div> 233 - <div class="shrink-0 flex flex-col items-end gap-1 text-xs text-neutral-600 tabular-nums"> 234 - <span>{format_duration(track.duration)}</span> 235 - {#if track.playCount} 236 - <span>{track.playCount} plays</span> 237 - {/if} 238 - {#if track.liked} 239 - <span class="text-rose-500">♥</span> 240 - {/if} 241 - </div> 242 - </li> 243 - {/each} 244 - </ul> 245 - {:else} 246 - <div class="flex flex-col items-center justify-center h-full gap-3 text-neutral-700 px-8 text-center"> 247 - <span class="text-4xl">⌕</span> 248 - <p class="text-xs">No tracks match your search or filter</p> 249 - </div> 250 - {/if} 251 - </div> 252 56 57 + {#if library} 58 + <Library {library} {open_button} /> 253 59 {:else} 254 - <!-- Empty state --> 255 60 <div class="flex flex-col items-center justify-center flex-1 gap-4 text-neutral-700 px-8 text-center"> 256 61 <span class="text-5xl">♪</span> 257 62 <div> 258 63 <p class="font-medium text-neutral-500">No library loaded</p> 259 - <p class="text-xs mt-1">Tap <strong class="text-neutral-400">Open</strong> to load a library JSON file</p> 64 + <div class="mt-4"> 65 + {@render open_button()} 66 + </div> 260 67 </div> 261 68 </div> 262 - {/if} 263 - 69 + {/if} 264 70 </div> 265 - 266 - <style> 267 - .no-scrollbar::-webkit-scrollbar { display: none; } 268 - .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } 269 - </style>
+382
mobile/src/routes/Library.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import type { Track, TrackList, Playlist, Folder, Special, LibraryTauri } from '../../bindings' 4 + 5 + type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount'; 6 + type sort_dir_type = 'asc' | 'desc'; 7 + type active_filter_type = { kind: 'all' } | { kind: 'liked' } | { kind: 'genre'; value: string }; 8 + type breadcrumb_entry = { id: string; name: string }; 9 + type view_type = 10 + | { kind: 'browser'; folder_id: string; breadcrumb: breadcrumb_entry[] } 11 + | { kind: 'tracks'; playlist_id: string; breadcrumb: breadcrumb_entry[] }; 12 + 13 + const { 14 + library, 15 + open_button, 16 + } = $props<{ 17 + library: LibraryTauri | null, 18 + open_button: Snippet<[]>, 19 + }>(); 20 + let error = $state(''); 21 + let view = $state<view_type>({ kind: 'browser', folder_id:'root', breadcrumb: [] }); 22 + let search_query = $state(''); 23 + let sort_key = $state<sort_key_type>('name'); 24 + let sort_dir = $state<sort_dir_type>('asc'); 25 + let active_filter = $state<active_filter_type>({ kind: 'all' }); 26 + 27 + // ── Helpers ──────────────────────────────────────────────────────────────── 28 + 29 + function get_tracklist(id: string): TrackList | null { 30 + return library?.track_lists?.[id] ?? null; 31 + } 32 + 33 + function get_special(id: string): (Special & { type: 'special' }) | null { 34 + const tl = get_tracklist(id); 35 + return tl?.type === 'special' ? tl : null; 36 + } 37 + 38 + function get_folder(id: string): (Folder & { type: 'folder' }) | null { 39 + const tl = get_tracklist(id); 40 + return tl?.type === 'folder' ? tl : null; 41 + } 42 + 43 + function get_playlist(id: string): (Playlist & { type: 'playlist' }) | null { 44 + const tl = get_tracklist(id); 45 + return tl?.type === 'playlist' ? tl : null; 46 + } 47 + 48 + function get_children(folder_id: string): string[] { 49 + const special = get_special(folder_id); 50 + if (special) return special.children; 51 + return get_folder(folder_id)?.children ?? []; 52 + } 53 + 54 + function node_name(id: string): string { 55 + const tl = get_tracklist(id); 56 + if (!tl) return 'Unknown'; 57 + if (tl.type === 'special') return 'Library'; 58 + return tl.name; 59 + } 60 + 61 + function count_tracks_in(id: string): number { 62 + const tl = get_tracklist(id); 63 + if (!tl) return 0; 64 + if (tl.type === 'playlist') return tl.tracks.length; 65 + const children = tl.type === 'folder' ? tl.children : tl.type === 'special' ? tl.children : []; 66 + return children.reduce((sum, child_id) => sum + count_tracks_in(child_id), 0); 67 + } 68 + 69 + // ── Navigation ───────────────────────────────────────────────────────────── 70 + 71 + // We can only navigate into folders or playlists from a browser view, 72 + // so prev.kind is always 'browser' here — no conditional needed. 73 + function open_folder(id: string) { 74 + if (view.kind !== 'browser') return; 75 + const new_crumb: breadcrumb_entry = { id: view.folder_id, name: node_name(view.folder_id) }; 76 + view = { 77 + kind: 'browser', 78 + folder_id: id, 79 + breadcrumb: [...view.breadcrumb, new_crumb], 80 + }; 81 + } 82 + 83 + function open_playlist(id: string) { 84 + if (view.kind !== 'browser') return; 85 + const new_crumb: breadcrumb_entry = { id: view.folder_id, name: node_name(view.folder_id) }; 86 + view = { 87 + kind: 'tracks', 88 + playlist_id: id, 89 + breadcrumb: [...view.breadcrumb, new_crumb], 90 + }; 91 + search_query = ''; 92 + active_filter = { kind: 'all' }; 93 + } 94 + 95 + function go_back() { 96 + const crumb = view.breadcrumb; 97 + if (crumb.length === 0) return; 98 + const parent = crumb[crumb.length - 1]; 99 + const new_crumb = crumb.slice(0, -1); 100 + const tl = get_tracklist(parent.id); 101 + if (tl?.type === 'folder' || tl?.type === 'special') { 102 + view = { kind: 'browser', folder_id: parent.id, breadcrumb: new_crumb }; 103 + } else if (tl?.type === 'playlist') { 104 + view = { kind: 'tracks', playlist_id: parent.id, breadcrumb: new_crumb }; 105 + } 106 + } 107 + 108 + // ── Derived data ─────────────────────────────────────────────────────────── 109 + 110 + const all_tracks = $derived( 111 + Object.values(library?.tracks ?? {}).filter((t): t is Track => t !== null) 112 + ); 113 + 114 + const current_children = $derived( 115 + view.kind === 'browser' ? get_children(view.folder_id) : [] 116 + ); 117 + 118 + const playlist_tracks = $derived( 119 + view.kind !== 'tracks' 120 + ? [] 121 + : (get_playlist(view.playlist_id)?.tracks ?? []) 122 + .map(item_id => all_tracks[item_id]) 123 + .filter((t): t is Track => t !== null) 124 + ); 125 + 126 + const genres = $derived( 127 + [...new Set(playlist_tracks.map(t => t.genre).filter((g): g is string => g !== null))].sort() 128 + ); 129 + 130 + const filtered_tracks = $derived( 131 + playlist_tracks 132 + .filter(t => { 133 + if (active_filter.kind === 'liked') return t.liked === true; 134 + if (active_filter.kind === 'genre') return t.genre === active_filter.value; 135 + return true; 136 + }) 137 + .filter(t => { 138 + if (!search_query) return true; 139 + const q = search_query.toLowerCase(); 140 + return ( 141 + t.name.toLowerCase().includes(q) || 142 + (t.artist?.toLowerCase().includes(q) ?? false) || 143 + (t.albumName?.toLowerCase().includes(q) ?? false) 144 + ); 145 + }) 146 + .sort((a, b) => { 147 + let av: string | number; 148 + let bv: string | number; 149 + if (sort_key === 'name') { av = a.name; bv = b.name; } 150 + else if (sort_key === 'artist') { av = a.artist ?? ''; bv = b.artist ?? ''; } 151 + else if (sort_key === 'dateAdded') { av = a.dateAdded; bv = b.dateAdded; } 152 + else { av = a.playCount ?? 0; bv = b.playCount ?? 0; } 153 + if (av < bv) return sort_dir === 'asc' ? -1 : 1; 154 + if (av > bv) return sort_dir === 'asc' ? 1 : -1; 155 + return 0; 156 + }) 157 + ); 158 + 159 + // ── Formatting ───────────────────────────────────────────────────────────── 160 + 161 + function toggle_sort(key: sort_key_type) { 162 + if (sort_key === key) sort_dir = sort_dir === 'asc' ? 'desc' : 'asc'; 163 + else { sort_key = key; sort_dir = 'asc'; } 164 + } 165 + 166 + function sort_indicator(key: sort_key_type): string { 167 + if (sort_key !== key) return ''; 168 + return sort_dir === 'asc' ? ' ↑' : ' ↓'; 169 + } 170 + 171 + function format_duration(seconds: number): string { 172 + const s = Math.floor(seconds); 173 + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; 174 + } 175 + </script> 176 + 177 + <div class="flex flex-col h-screen bg-neutral-950 text-neutral-200 text-sm overflow-hidden"> 178 + 179 + <!-- Header --> 180 + <header class="flex items-center gap-2 px-4 py-3 border-b border-neutral-800 shrink-0"> 181 + {#if view.breadcrumb.length > 0} 182 + <button 183 + type="button" 184 + onclick={go_back} 185 + class="shrink-0 flex items-center justify-center w-8 h-8 text-xl rounded-lg text-neutral-400 hover:bg-neutral-800 hover:text-neutral-100 transition-colors" 186 + aria-label="Back" 187 + >‹</button> 188 + {/if} 189 + 190 + <div class="flex-1 min-w-0"> 191 + {#if view.kind === 'browser'} 192 + <p class="font-semibold text-neutral-100 truncate leading-tight">{node_name(view.folder_id)}</p> 193 + {:else if view.kind === 'tracks'} 194 + <p class="font-semibold text-neutral-100 truncate leading-tight">{node_name(view.playlist_id)}</p> 195 + {/if} 196 + {#if view.breadcrumb.length > 0} 197 + <p class="text-xs text-neutral-600 truncate">{view.breadcrumb.map(b => b.name).join(' › ')}</p> 198 + {/if} 199 + </div> 200 + 201 + {@render open_button()} 202 + </header> 203 + 204 + {#if error} 205 + <div class="px-4 py-2.5 bg-red-950 border-b border-red-900 text-red-400 text-xs shrink-0">⚠ {error}</div> 206 + {/if} 207 + 208 + <!-- ── Browser view ───────────────────────────────────────────────────── --> 209 + {#if view.kind === 'browser'} 210 + <div class="flex-1 overflow-y-auto"> 211 + {#if current_children.length > 0} 212 + <ul class="divide-y divide-neutral-900"> 213 + {#each current_children as child_id} 214 + {@const tl = get_tracklist(child_id)} 215 + {#if tl?.type === 'folder'} 216 + <li> 217 + <button 218 + type="button" 219 + onclick={() => open_folder(child_id)} 220 + class="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-neutral-800/50 active:bg-neutral-800 transition-colors text-left" 221 + > 222 + <span class="shrink-0 w-7 text-center text-neutral-500 select-none"> 223 + <svg 224 + style="padding: 1px" 225 + xmlns="http://www.w3.org/2000/svg" 226 + height="22px" 227 + viewBox="0 0 24 24" 228 + width="22px" 229 + fill="currentColor" 230 + ><path d="M0 0h24v24H0V0z" fill="none" /><path 231 + d="M9.17 6l2 2H20v10H4V6h5.17M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" 232 + /></svg 233 + > 234 + </span> 235 + <div class="flex-1 min-w-0"> 236 + <p class="font-medium text-neutral-100 truncate">{tl.name}</p> 237 + <p class="text-xs text-neutral-500 mt-0.5"> 238 + {tl.children.length} {tl.children.length === 1 ? 'item' : 'items'} · {count_tracks_in(child_id)} tracks 239 + </p> 240 + </div> 241 + <span class="text-neutral-700 text-lg select-none">›</span> 242 + </button> 243 + </li> 244 + {:else if tl?.type === 'playlist'} 245 + <li> 246 + <button 247 + type="button" 248 + onclick={() => open_playlist(child_id)} 249 + class="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-neutral-800/50 active:bg-neutral-800 transition-colors text-left" 250 + > 251 + <span class="shrink-0 w-7 text-center text-neutral-500 select-none"> 252 + <svg 253 + style="transform: translateX(2px)" 254 + xmlns="http://www.w3.org/2000/svg" 255 + height="24px" 256 + viewBox="0 0 24 24" 257 + width="24px" 258 + fill="currentColor" 259 + ><path 260 + d="M5 10h10c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1s.45-1 1-1zm0-4h10c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1s.45-1 1-1zm0 8h6c.55 0 1 .45 1 1s-.45 1-1 1H5c-.55 0-1-.45-1-1s.45-1 1-1zm9 .88v4.23c0 .39.42.63.76.43l3.53-2.12c.32-.19.32-.66 0-.86l-3.53-2.12c-.34-.19-.76.05-.76.44z" 261 + /></svg 262 + > 263 + </span> 264 + <div class="flex-1 min-w-0"> 265 + <p class="font-medium text-neutral-100 truncate">{tl.name}</p> 266 + <p class="text-xs text-neutral-500 mt-0.5"> 267 + {tl.tracks.length} {tl.tracks.length === 1 ? 'track' : 'tracks'}{tl.liked ? ' · ♥' : ''} 268 + </p> 269 + </div> 270 + <span class="text-neutral-700 text-lg select-none">›</span> 271 + </button> 272 + </li> 273 + {/if} 274 + {/each} 275 + </ul> 276 + {:else} 277 + <div class="flex items-center justify-center h-32 text-neutral-700 text-xs">This folder is empty</div> 278 + {/if} 279 + </div> 280 + 281 + <!-- ── Tracks view ────────────────────────────────────────────────────── --> 282 + {:else if view.kind === 'tracks'} 283 + <div class="shrink-0 border-b border-neutral-800"> 284 + <div class="flex items-center gap-1.5 px-4 py-2 overflow-x-auto no-scrollbar"> 285 + <div class="relative shrink-0"> 286 + <span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none text-xs select-none">⌕</span> 287 + <input 288 + type="search" 289 + placeholder="Search…" 290 + bind:value={search_query} 291 + class="pl-7 pr-3 py-1.5 w-32 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 text-xs placeholder-neutral-600 outline-none focus:border-neutral-500 transition-colors" 292 + /> 293 + </div> 294 + <div class="w-px h-4 bg-neutral-800 shrink-0 mx-0.5"></div> 295 + <button type="button" onclick={() => toggle_sort('name')} 296 + class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'name' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 297 + Name{sort_indicator('name')} 298 + </button> 299 + <button type="button" onclick={() => toggle_sort('artist')} 300 + class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'artist' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 301 + Artist{sort_indicator('artist')} 302 + </button> 303 + <button type="button" onclick={() => toggle_sort('dateAdded')} 304 + class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'dateAdded' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 305 + Date{sort_indicator('dateAdded')} 306 + </button> 307 + <button type="button" onclick={() => toggle_sort('playCount')} 308 + class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'playCount' ? 'bg-neutral-700 border-neutral-600 text-neutral-100' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 309 + Plays{sort_indicator('playCount')} 310 + </button> 311 + <span class="ml-auto shrink-0 text-xs text-neutral-600 tabular-nums pl-1"> 312 + {filtered_tracks.length}/{playlist_tracks.length} 313 + </span> 314 + </div> 315 + 316 + <div class="flex items-center gap-1.5 px-4 pb-2 overflow-x-auto no-scrollbar"> 317 + <button type="button" onclick={() => active_filter = { kind: 'all' }} 318 + class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'all' ? 'bg-neutral-200 border-neutral-200 text-neutral-900 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 319 + All 320 + </button> 321 + <button type="button" onclick={() => active_filter = { kind: 'liked' }} 322 + class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'liked' ? 'bg-rose-900 border-rose-700 text-rose-200 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 323 + ♥ Liked 324 + </button> 325 + {#each genres as genre} 326 + <button type="button" onclick={() => active_filter = { kind: 'genre', value: genre }} 327 + class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'genre' && active_filter.value === genre ? 'bg-neutral-700 border-neutral-600 text-neutral-100 font-semibold' : 'border-neutral-700 text-neutral-500 hover:text-neutral-300'}"> 328 + {genre} 329 + </button> 330 + {/each} 331 + </div> 332 + </div> 333 + 334 + <div class="flex-1 overflow-y-auto"> 335 + {#if filtered_tracks.length > 0} 336 + <ul class="divide-y divide-neutral-900"> 337 + {#each filtered_tracks as track} 338 + <li class="flex items-center gap-3 px-4 py-3 hover:bg-neutral-800/50 active:bg-neutral-800 transition-colors"> 339 + <div class="flex-1 min-w-0"> 340 + <p class="font-medium text-neutral-100 truncate">{track.name}</p> 341 + <p class="text-xs text-neutral-500 truncate mt-0.5"> 342 + {track.artist ?? 'Unknown Artist'} 343 + {#if track.albumName} 344 + <span class="text-neutral-700"> · </span>{track.albumName} 345 + {/if} 346 + </p> 347 + <div class="flex items-center gap-2 mt-1"> 348 + {#if track.genre} 349 + <span class="px-1.5 py-px text-xs rounded bg-neutral-800 text-neutral-500">{track.genre}</span> 350 + {/if} 351 + {#if track.year} 352 + <span class="text-xs text-neutral-700">{track.year}</span> 353 + {/if} 354 + </div> 355 + </div> 356 + <div class="shrink-0 flex flex-col items-end gap-1 text-xs text-neutral-600 tabular-nums"> 357 + <span>{format_duration(track.duration)}</span> 358 + {#if track.playCount} 359 + <span>{track.playCount} plays</span> 360 + {/if} 361 + {#if track.liked} 362 + <span class="text-rose-500">♥</span> 363 + {/if} 364 + </div> 365 + </li> 366 + {/each} 367 + </ul> 368 + {:else} 369 + <div class="flex flex-col items-center justify-center h-full gap-3 text-neutral-700 px-8 text-center"> 370 + <span class="text-4xl">⌕</span> 371 + <p class="text-xs">No tracks match your search or filter</p> 372 + </div> 373 + {/if} 374 + </div> 375 + {/if} 376 + 377 + </div> 378 + 379 + <style> 380 + .no-scrollbar::-webkit-scrollbar { display: none; } 381 + .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } 382 + </style>