[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.

fmt

Kasper (Mar 5, 2026, 2:03 PM +0100) d38e553d a882b9de

+498 -402
+2 -2
mobile/src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import './layout.css'; 2 + import './layout.css' 3 3 4 - const { children } = $props(); 4 + const { children } = $props() 5 5 </script> 6 6 7 7 {@render children()}
+1 -1
mobile/src/routes/+layout.ts
··· 2 2 // so we use adapter-static with a fallback to index.html to put the site in SPA mode 3 3 // See: https://svelte.dev/docs/kit/single-page-apps 4 4 // See: https://v2.tauri.app/start/frontend/sveltekit/ for more info 5 - export const ssr = false; 5 + export const ssr = false
+73 -69
mobile/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import commands from '$lib/commands' 3 - import { open } from '@tauri-apps/plugin-dialog'; 4 - import type { LibraryTauri } from '../../bindings' 5 - import { readTextFile } from '@tauri-apps/plugin-fs' 2 + import commands from '$lib/commands' 3 + import { open } from '@tauri-apps/plugin-dialog' 4 + import type { LibraryTauri } from '../../bindings' 5 + import { readTextFile } from '@tauri-apps/plugin-fs' 6 6 import Library from './Library.svelte' 7 7 import { Store } from '@tauri-apps/plugin-store' 8 8 9 + let loading = $state(false) 10 + let error = $state('') 9 11 10 - let loading = $state(false); 11 - let error = $state(''); 12 + const store = await Store.load('settings.json') 13 + let library = $state<LibraryTauri | null>(null) 12 14 13 - const store = await Store.load('settings.json'); 14 - let library = $state<LibraryTauri | null>(null); 15 + const saved_library_path = await store.get('library_path') 16 + if (typeof saved_library_path === 'string') { 17 + load_library(saved_library_path) 18 + } 15 19 16 - const saved_library_path = await store.get('library_path') 17 - if (typeof saved_library_path === 'string') { 18 - load_library(saved_library_path) 19 - } 20 - 21 - async function open_library() { 22 - const path = await open({ 23 - filters: [{ name: 'JSON', extensions: ['json'] }], 24 - }); 25 - if (path) { 26 - load_library(path) 27 - } 28 - } 29 - console.log(await store.entries()) 30 - async function load_library(path: string) { 31 - loading = true; 32 - library = null 33 - error = ''; 34 - try { 35 - const contents = await readTextFile(path); 36 - const result = await commands.loadLibrary(contents); 37 - if (result.status === 'ok') { 38 - library = result.data; 39 - store.set('library_path', path) 40 - store.save() 41 - } else { 42 - error = result.error; 43 - } 44 - } catch (e) { 45 - error = e instanceof Error ? e.message : 'Failed to load library'; 46 - } finally { 47 - loading = false; 48 - } 49 - } 20 + async function open_library() { 21 + const path = await open({ 22 + filters: [{ name: 'JSON', extensions: ['json'] }], 23 + }) 24 + if (path) { 25 + load_library(path) 26 + } 27 + } 28 + console.log(await store.entries()) 29 + async function load_library(path: string) { 30 + loading = true 31 + library = null 32 + error = '' 33 + try { 34 + const contents = await readTextFile(path) 35 + const result = await commands.loadLibrary(contents) 36 + if (result.status === 'ok') { 37 + library = result.data 38 + store.set('library_path', path) 39 + store.save() 40 + } else { 41 + error = result.error 42 + } 43 + } catch (e) { 44 + error = e instanceof Error ? e.message : 'Failed to load library' 45 + } finally { 46 + loading = false 47 + } 48 + } 50 49 </script> 51 50 52 - <div class="flex flex-col h-screen bg-white dark:bg-neutral-950 text-neutral-800 dark:text-neutral-200 text-sm overflow-hidden scheme-light-dark"> 53 - {#if error} 54 - <div class="px-4 py-2.5 bg-red-950 border-b border-red-900 text-red-400 text-xs shrink-0">⚠ {error}</div> 55 - {/if} 51 + <div 52 + class="flex h-screen flex-col overflow-hidden bg-white text-sm text-neutral-800 scheme-light-dark dark:bg-neutral-950 dark:text-neutral-200" 53 + > 54 + {#if error} 55 + <div class="shrink-0 border-b border-red-900 bg-red-950 px-4 py-2.5 text-xs text-red-400"> 56 + ⚠ {error} 57 + </div> 58 + {/if} 56 59 57 - {#snippet open_button()} 58 - <button 59 - type="button" 60 - onclick={open_library} 61 - disabled={loading} 62 - class="shrink-0 px-3 py-1.5 bg-neutral-900 text-neutral-100 dark:bg-neutral-100 dark:text-neutral-900 rounded-lg font-semibold text-xs hover:bg-neutral-700 dark:hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 63 - > 64 - {loading ? 'Loading…' : 'Open'} 65 - </button> 66 - {/snippet} 60 + {#snippet open_button()} 61 + <button 62 + type="button" 63 + onclick={open_library} 64 + disabled={loading} 65 + class="shrink-0 rounded-lg bg-neutral-900 px-3 py-1.5 text-xs font-semibold text-neutral-100 transition-colors hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-white" 66 + > 67 + {loading ? 'Loading…' : 'Open'} 68 + </button> 69 + {/snippet} 67 70 68 - 69 - {#if library} 70 - <Library {library} {open_button} /> 71 - {:else} 72 - <div class="flex flex-col items-center justify-center flex-1 gap-4 text-neutral-400 dark:text-neutral-700 px-8 text-center"> 73 - <span class="text-5xl">♪</span> 74 - <div> 75 - <p class="font-medium text-neutral-500 dark:text-neutral-500">No library loaded</p> 76 - <div class="mt-4"> 77 - {@render open_button()} 78 - </div> 79 - </div> 80 - </div> 71 + {#if library} 72 + <Library {library} {open_button} /> 73 + {:else} 74 + <div 75 + class="flex flex-1 flex-col items-center justify-center gap-4 px-8 text-center text-neutral-400 dark:text-neutral-700" 76 + > 77 + <span class="text-5xl">♪</span> 78 + <div> 79 + <p class="font-medium text-neutral-500 dark:text-neutral-500">No library loaded</p> 80 + <div class="mt-4"> 81 + {@render open_button()} 82 + </div> 83 + </div> 84 + </div> 81 85 {/if} 82 86 </div>
+422 -330
mobile/src/routes/Library.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Snippet } from 'svelte' 3 - import { goto } from '$app/navigation' 4 - import { page } from '$app/state' 5 - import type { Track, TrackList, Playlist, Folder, Special, LibraryTauri } from '../../bindings' 3 + import { goto } from '$app/navigation' 4 + import { page } from '$app/state' 5 + import type { Track, TrackList, Playlist, Folder, Special, LibraryTauri } from '../../bindings' 6 6 import { resolve } from '$app/paths' 7 7 8 - type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount'; 9 - type sort_dir_type = 'asc' | 'desc'; 10 - type active_filter_type = { kind: 'all' } | { kind: 'liked' } | { kind: 'genre'; value: string }; 11 - type view_type = 12 - | { kind: 'browser'; folder_id: string } 13 - | { kind: 'tracks'; playlist_id: string }; 8 + type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount' 9 + type sort_dir_type = 'asc' | 'desc' 10 + type active_filter_type = { kind: 'all' } | { kind: 'liked' } | { kind: 'genre'; value: string } 11 + type view_type = { kind: 'browser'; folder_id: string } | { kind: 'tracks'; playlist_id: string } 14 12 15 - const { 16 - library, 17 - open_button, 18 - } = $props<{ 19 - library: LibraryTauri | null, 20 - open_button: Snippet<[]>, 21 - }>(); 22 - let error = $state(''); 23 - let search_query = $state(''); 24 - let sort_key = $state<sort_key_type>('name'); 25 - let sort_dir = $state<sort_dir_type>('asc'); 26 - let active_filter = $state<active_filter_type>({ kind: 'all' }); 13 + const { library, open_button } = $props<{ 14 + library: LibraryTauri | null 15 + open_button: Snippet<[]> 16 + }>() 17 + let error = $state('') 18 + let search_query = $state('') 19 + let sort_key = $state<sort_key_type>('name') 20 + let sort_dir = $state<sort_dir_type>('asc') 21 + let active_filter = $state<active_filter_type>({ kind: 'all' }) 27 22 28 - // ── View derived from URL search params ──────────────────────────────────── 29 - const view = $derived<view_type>( 30 - page.url.searchParams.get('view') === 'tracks' 31 - ? { kind: 'tracks', playlist_id: page.url.searchParams.get('id') ?? 'root' } 32 - : { kind: 'browser', folder_id: page.url.searchParams.get('id') ?? 'root' } 33 - ); 23 + // ── View derived from URL search params ──────────────────────────────────── 24 + const view = $derived<view_type>( 25 + page.url.searchParams.get('view') === 'tracks' 26 + ? { kind: 'tracks', playlist_id: page.url.searchParams.get('id') ?? 'root' } 27 + : { kind: 'browser', folder_id: page.url.searchParams.get('id') ?? 'root' }, 28 + ) 34 29 35 - // ── Helpers ──────────────────────────────────────────────────────────────── 30 + // ── Helpers ──────────────────────────────────────────────────────────────── 36 31 37 - function get_tracklist(id: string): TrackList | null { 38 - return library?.track_lists?.[id] ?? null; 39 - } 32 + function get_tracklist(id: string): TrackList | null { 33 + return library?.track_lists?.[id] ?? null 34 + } 40 35 41 - function get_special(id: string): (Special & { type: 'special' }) | null { 42 - const tl = get_tracklist(id); 43 - return tl?.type === 'special' ? tl : null; 44 - } 36 + function get_special(id: string): (Special & { type: 'special' }) | null { 37 + const tl = get_tracklist(id) 38 + return tl?.type === 'special' ? tl : null 39 + } 45 40 46 - function get_folder(id: string): (Folder & { type: 'folder' }) | null { 47 - const tl = get_tracklist(id); 48 - return tl?.type === 'folder' ? tl : null; 49 - } 41 + function get_folder(id: string): (Folder & { type: 'folder' }) | null { 42 + const tl = get_tracklist(id) 43 + return tl?.type === 'folder' ? tl : null 44 + } 50 45 51 - function get_playlist(id: string): (Playlist & { type: 'playlist' }) | null { 52 - const tl = get_tracklist(id); 53 - return tl?.type === 'playlist' ? tl : null; 54 - } 46 + function get_playlist(id: string): (Playlist & { type: 'playlist' }) | null { 47 + const tl = get_tracklist(id) 48 + return tl?.type === 'playlist' ? tl : null 49 + } 55 50 56 - function get_children(folder_id: string): string[] { 57 - const special = get_special(folder_id); 58 - if (special) return special.children; 59 - return get_folder(folder_id)?.children ?? []; 60 - } 51 + function get_children(folder_id: string): string[] { 52 + const special = get_special(folder_id) 53 + if (special) return special.children 54 + return get_folder(folder_id)?.children ?? [] 55 + } 61 56 62 - function node_name(id: string): string { 63 - const tl = get_tracklist(id); 64 - if (!tl) return 'Unknown'; 65 - if (tl.type === 'special') return 'Library'; 66 - return tl.name; 67 - } 57 + function node_name(id: string): string { 58 + const tl = get_tracklist(id) 59 + if (!tl) return 'Unknown' 60 + if (tl.type === 'special') return 'Library' 61 + return tl.name 62 + } 68 63 69 - function count_tracks_in(id: string): number { 70 - const tl = get_tracklist(id); 71 - if (!tl) return 0; 72 - if (tl.type === 'playlist') return tl.tracks.length; 73 - const children = tl.type === 'folder' ? tl.children : tl.type === 'special' ? tl.children : []; 74 - return children.reduce((sum, child_id) => sum + count_tracks_in(child_id), 0); 75 - } 64 + function count_tracks_in(id: string): number { 65 + const tl = get_tracklist(id) 66 + if (!tl) return 0 67 + if (tl.type === 'playlist') return tl.tracks.length 68 + const children = tl.type === 'folder' ? tl.children : tl.type === 'special' ? tl.children : [] 69 + return children.reduce((sum, child_id) => sum + count_tracks_in(child_id), 0) 70 + } 76 71 77 - // ── Navigation ───────────────────────────────────────────────────────────── 72 + // ── Navigation ───────────────────────────────────────────────────────────── 78 73 79 - function open_folder(id: string) { 80 - // eslint-disable-next-line svelte/no-navigation-without-resolve 81 - goto(resolve('/') + `?view=browser&id=${id}`); 82 - } 74 + function open_folder(id: string) { 75 + // eslint-disable-next-line svelte/no-navigation-without-resolve 76 + goto(resolve('/') + `?view=browser&id=${id}`) 77 + } 83 78 84 - function open_playlist(id: string) { 85 - search_query = ''; 86 - active_filter = { kind: 'all' }; 87 - // eslint-disable-next-line svelte/no-navigation-without-resolve 88 - goto(resolve('/') + `?view=tracks&id=${id}`); 89 - } 79 + function open_playlist(id: string) { 80 + search_query = '' 81 + active_filter = { kind: 'all' } 82 + // eslint-disable-next-line svelte/no-navigation-without-resolve 83 + goto(resolve('/') + `?view=tracks&id=${id}`) 84 + } 90 85 91 - // ── Derived / async data ────────────────────────────────────────────────── 86 + // ── Derived / async data ────────────────────────────────────────────────── 92 87 93 - const current_children = $derived( 94 - view.kind === 'browser' ? get_children(view.folder_id) : [] 95 - ); 88 + const current_children = $derived(view.kind === 'browser' ? get_children(view.folder_id) : []) 96 89 97 - // Playlist.tracks is typed as number[] in the bindings, but serialize_playlist_ids 98 - // in Rust serializes them back to track ID strings over the wire. 99 - const playlist_tracks = $derived.by(() => { 100 - if (view.kind !== 'tracks') return []; 101 - const playlist = get_playlist(view.playlist_id); 102 - if (!playlist) { 103 - console.error('[Library] no playlist found for id', view.playlist_id); 104 - return []; 105 - } 106 - const track_ids = playlist.tracks as unknown as string[]; 107 - const resolved = track_ids.map(track_id => { 108 - const track = library?.tracks?.[track_id]; 109 - if (track === undefined) console.error('[Library] no track for track_id', track_id); 110 - return track; 111 - }).filter((t): t is Track => t !== undefined); 112 - return resolved; 113 - }); 90 + // Playlist.tracks is typed as number[] in the bindings, but serialize_playlist_ids 91 + // in Rust serializes them back to track ID strings over the wire. 92 + const playlist_tracks = $derived.by(() => { 93 + if (view.kind !== 'tracks') return [] 94 + const playlist = get_playlist(view.playlist_id) 95 + if (!playlist) { 96 + console.error('[Library] no playlist found for id', view.playlist_id) 97 + return [] 98 + } 99 + const track_ids = playlist.tracks as unknown as string[] 100 + const resolved = track_ids 101 + .map((track_id) => { 102 + const track = library?.tracks?.[track_id] 103 + if (track === undefined) console.error('[Library] no track for track_id', track_id) 104 + return track 105 + }) 106 + .filter((t): t is Track => t !== undefined) 107 + return resolved 108 + }) 114 109 115 - const genres = $derived( 116 - [...new Set(playlist_tracks.map(t => t.genre).filter((g): g is string => g !== null && g !== undefined))].sort() 117 - ); 110 + const genres = $derived( 111 + [ 112 + ...new Set( 113 + playlist_tracks 114 + .map((t) => t.genre) 115 + .filter((g): g is string => g !== null && g !== undefined), 116 + ), 117 + ].sort(), 118 + ) 118 119 119 - const filtered_tracks = $derived( 120 - playlist_tracks 121 - .filter(t => { 122 - if (active_filter.kind === 'liked') return t.liked === true; 123 - if (active_filter.kind === 'genre') return t.genre === active_filter.value; 124 - return true; 125 - }) 126 - .filter(t => { 127 - if (!search_query) return true; 128 - const q = search_query.toLowerCase(); 129 - return ( 130 - t.name.toLowerCase().includes(q) || 131 - (t.artist?.toLowerCase().includes(q) ?? false) || 132 - (t.albumName?.toLowerCase().includes(q) ?? false) 133 - ); 134 - }) 135 - .sort((a, b) => { 136 - let av: string | number; 137 - let bv: string | number; 138 - if (sort_key === 'name') { av = a.name; bv = b.name; } 139 - else if (sort_key === 'artist') { av = a.artist ?? ''; bv = b.artist ?? ''; } 140 - else if (sort_key === 'dateAdded') { av = a.dateAdded; bv = b.dateAdded; } 141 - else { av = a.playCount ?? 0; bv = b.playCount ?? 0; } 142 - if (av < bv) return sort_dir === 'asc' ? -1 : 1; 143 - if (av > bv) return sort_dir === 'asc' ? 1 : -1; 144 - return 0; 145 - }) 146 - ); 120 + const filtered_tracks = $derived( 121 + playlist_tracks 122 + .filter((t) => { 123 + if (active_filter.kind === 'liked') return t.liked === true 124 + if (active_filter.kind === 'genre') return t.genre === active_filter.value 125 + return true 126 + }) 127 + .filter((t) => { 128 + if (!search_query) return true 129 + const q = search_query.toLowerCase() 130 + return ( 131 + t.name.toLowerCase().includes(q) || 132 + (t.artist?.toLowerCase().includes(q) ?? false) || 133 + (t.albumName?.toLowerCase().includes(q) ?? false) 134 + ) 135 + }) 136 + .sort((a, b) => { 137 + let av: string | number 138 + let bv: string | number 139 + if (sort_key === 'name') { 140 + av = a.name 141 + bv = b.name 142 + } else if (sort_key === 'artist') { 143 + av = a.artist ?? '' 144 + bv = b.artist ?? '' 145 + } else if (sort_key === 'dateAdded') { 146 + av = a.dateAdded 147 + bv = b.dateAdded 148 + } else { 149 + av = a.playCount ?? 0 150 + bv = b.playCount ?? 0 151 + } 152 + if (av < bv) return sort_dir === 'asc' ? -1 : 1 153 + if (av > bv) return sort_dir === 'asc' ? 1 : -1 154 + return 0 155 + }), 156 + ) 147 157 148 - // ── Formatting ───────────────────────────────────────────────────────────── 158 + // ── Formatting ───────────────────────────────────────────────────────────── 149 159 150 - function toggle_sort(key: sort_key_type) { 151 - if (sort_key === key) sort_dir = sort_dir === 'asc' ? 'desc' : 'asc'; 152 - else { sort_key = key; sort_dir = 'asc'; } 153 - } 160 + function toggle_sort(key: sort_key_type) { 161 + if (sort_key === key) sort_dir = sort_dir === 'asc' ? 'desc' : 'asc' 162 + else { 163 + sort_key = key 164 + sort_dir = 'asc' 165 + } 166 + } 154 167 155 - function sort_indicator(key: sort_key_type): string { 156 - if (sort_key !== key) return ''; 157 - return sort_dir === 'asc' ? ' ↑' : ' ↓'; 158 - } 168 + function sort_indicator(key: sort_key_type): string { 169 + if (sort_key !== key) return '' 170 + return sort_dir === 'asc' ? ' ↑' : ' ↓' 171 + } 159 172 160 - function format_duration(seconds: number): string { 161 - const s = Math.floor(seconds); 162 - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; 163 - } 173 + function format_duration(seconds: number): string { 174 + const s = Math.floor(seconds) 175 + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` 176 + } 164 177 </script> 165 178 166 - <div class="flex flex-col h-screen bg-white dark:bg-neutral-950 text-neutral-800 dark:text-neutral-200 text-sm overflow-hidden"> 179 + <div 180 + class="flex h-screen flex-col overflow-hidden bg-white text-sm text-neutral-800 dark:bg-neutral-950 dark:text-neutral-200" 181 + > 182 + <!-- Header --> 183 + <header 184 + class="flex shrink-0 items-center gap-2 border-b border-neutral-200 px-4 py-3 dark:border-neutral-800" 185 + > 186 + {#if page.url.searchParams.has('id')} 187 + <button 188 + type="button" 189 + onclick={() => history.back()} 190 + class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xl text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100" 191 + aria-label="Back">‹</button 192 + > 193 + {/if} 167 194 168 - <!-- Header --> 169 - <header class="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 shrink-0"> 170 - {#if page.url.searchParams.has('id')} 171 - <button 172 - type="button" 173 - onclick={() => history.back()} 174 - class="shrink-0 flex items-center justify-center w-8 h-8 text-xl rounded-lg text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors" 175 - aria-label="Back" 176 - >‹</button> 177 - {/if} 178 - 179 - <div class="flex-1 min-w-0"> 180 - {#if view.kind === 'browser'} 181 - <p class="font-semibold text-neutral-900 dark:text-neutral-100 truncate leading-tight">{node_name(view.folder_id)}</p> 182 - {:else if view.kind === 'tracks'} 183 - <p class="font-semibold text-neutral-900 dark:text-neutral-100 truncate leading-tight">{node_name(view.playlist_id)}</p> 184 - {/if} 185 - 186 - </div> 187 - 188 - {@render open_button()} 189 - </header> 195 + <div class="min-w-0 flex-1"> 196 + {#if view.kind === 'browser'} 197 + <p class="truncate leading-tight font-semibold text-neutral-900 dark:text-neutral-100"> 198 + {node_name(view.folder_id)} 199 + </p> 200 + {:else if view.kind === 'tracks'} 201 + <p class="truncate leading-tight font-semibold text-neutral-900 dark:text-neutral-100"> 202 + {node_name(view.playlist_id)} 203 + </p> 204 + {/if} 205 + </div> 190 206 191 - {#if error} 192 - <div class="px-4 py-2.5 bg-red-50 dark:bg-red-950 border-b border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 text-xs shrink-0">⚠ {error}</div> 193 - {/if} 207 + {@render open_button()} 208 + </header> 194 209 195 - <!-- ── Browser view ───────────────────────────────────────────────────── --> 196 - {#if view.kind === 'browser'} 197 - <div class="flex-1 overflow-y-auto"> 198 - {#if current_children.length > 0} 199 - <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 200 - {#each current_children as child_id} 201 - {@const tl = get_tracklist(child_id)} 202 - {#if tl?.type === 'folder'} 203 - <li> 204 - <button 205 - type="button" 206 - onclick={() => open_folder(child_id)} 207 - class="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 active:bg-neutral-100 dark:active:bg-neutral-800 transition-colors text-left" 208 - > 209 - <span class="shrink-0 w-7 text-center text-neutral-500 select-none"> 210 - <svg 211 - style="padding: 1px" 212 - xmlns="http://www.w3.org/2000/svg" 213 - height="22px" 214 - viewBox="0 0 24 24" 215 - width="22px" 216 - fill="currentColor" 217 - ><path d="M0 0h24v24H0V0z" fill="none" /><path 218 - 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" 219 - /></svg 220 - > 221 - </span> 222 - <div class="flex-1 min-w-0"> 223 - <p class="font-medium text-neutral-900 dark:text-neutral-100 truncate">{tl.name}</p> 224 - <p class="text-xs text-neutral-500 mt-0.5"> 225 - {tl.children.length} {tl.children.length === 1 ? 'item' : 'items'} · {count_tracks_in(child_id)} tracks 226 - </p> 227 - </div> 228 - <span class="text-neutral-400 dark:text-neutral-700 text-lg select-none">›</span> 229 - </button> 230 - </li> 231 - {:else if tl?.type === 'playlist'} 232 - <li> 233 - <button 234 - type="button" 235 - onclick={() => open_playlist(child_id)} 236 - class="w-full flex items-center gap-3 px-4 py-3.5 hover:bg-neutral-100/50 dark:hover:bg-neutral-800/50 active:bg-neutral-100 dark:active:bg-neutral-800 transition-colors text-left" 237 - > 238 - <span class="shrink-0 w-7 text-center text-neutral-500 select-none"> 239 - <svg 240 - style="transform: translateX(2px)" 241 - xmlns="http://www.w3.org/2000/svg" 242 - height="24px" 243 - viewBox="0 0 24 24" 244 - width="24px" 245 - fill="currentColor" 246 - ><path 247 - 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" 248 - /></svg 249 - > 250 - </span> 251 - <div class="flex-1 min-w-0"> 252 - <p class="font-medium text-neutral-900 dark:text-neutral-100 truncate">{tl.name}</p> 253 - <p class="text-xs text-neutral-500 mt-0.5"> 254 - {tl.tracks.length} {tl.tracks.length === 1 ? 'track' : 'tracks'}{tl.liked ? ' · ♥' : ''} 255 - </p> 256 - </div> 257 - <span class="text-neutral-400 dark:text-neutral-700 text-lg select-none">›</span> 258 - </button> 259 - </li> 260 - {/if} 261 - {/each} 262 - </ul> 263 - {:else} 264 - <div class="flex items-center justify-center h-32 text-neutral-400 dark:text-neutral-700 text-xs">This folder is empty</div> 265 - {/if} 266 - </div> 210 + {#if error} 211 + <div 212 + class="shrink-0 border-b border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-600 dark:border-red-900 dark:bg-red-950 dark:text-red-400" 213 + > 214 + ⚠ {error} 215 + </div> 216 + {/if} 267 217 268 - <!-- ── Tracks view ────────────────────────────────────────────────────── --> 269 - {:else if view.kind === 'tracks'} 270 - <div class="shrink-0 border-b border-neutral-200 dark:border-neutral-800"> 271 - <div class="flex items-center gap-1.5 px-4 py-2 overflow-x-auto no-scrollbar"> 272 - <div class="relative shrink-0"> 273 - <span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-neutral-500 pointer-events-none text-xs select-none">⌕</span> 274 - <input 275 - type="search" 276 - placeholder="Search…" 277 - bind:value={search_query} 278 - class="pl-7 pr-3 py-1.5 w-32 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-lg text-neutral-800 dark:text-neutral-200 text-xs placeholder-neutral-400 dark:placeholder-neutral-600 outline-none focus:border-neutral-400 dark:focus:border-neutral-500 transition-colors" 279 - /> 280 - </div> 281 - <div class="w-px h-4 bg-neutral-100 dark:bg-neutral-800 shrink-0 mx-0.5"></div> 282 - <button type="button" onclick={() => toggle_sort('name')} 283 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'name' ? 'bg-neutral-200 dark:bg-neutral-700 border-neutral-400 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 284 - Name{sort_indicator('name')} 285 - </button> 286 - <button type="button" onclick={() => toggle_sort('artist')} 287 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'artist' ? 'bg-neutral-200 dark:bg-neutral-700 border-neutral-400 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 288 - Artist{sort_indicator('artist')} 289 - </button> 290 - <button type="button" onclick={() => toggle_sort('dateAdded')} 291 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'dateAdded' ? 'bg-neutral-200 dark:bg-neutral-700 border-neutral-400 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 292 - Date{sort_indicator('dateAdded')} 293 - </button> 294 - <button type="button" onclick={() => toggle_sort('playCount')} 295 - class="shrink-0 px-2.5 py-1 rounded text-xs border transition-colors {sort_key === 'playCount' ? 'bg-neutral-200 dark:bg-neutral-700 border-neutral-400 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 296 - Plays{sort_indicator('playCount')} 297 - </button> 298 - <span class="ml-auto shrink-0 text-xs text-neutral-400 dark:text-neutral-600 tabular-nums pl-1"> 299 - {filtered_tracks.length}/{playlist_tracks.length} 300 - </span> 301 - </div> 218 + <!-- ── Browser view ───────────────────────────────────────────────────── --> 219 + {#if view.kind === 'browser'} 220 + <div class="flex-1 overflow-y-auto"> 221 + {#if current_children.length > 0} 222 + <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 223 + {#each current_children as child_id} 224 + {@const tl = get_tracklist(child_id)} 225 + {#if tl?.type === 'folder'} 226 + <li> 227 + <button 228 + type="button" 229 + onclick={() => open_folder(child_id)} 230 + class="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-neutral-100/50 active:bg-neutral-100 dark:hover:bg-neutral-800/50 dark:active:bg-neutral-800" 231 + > 232 + <span class="w-7 shrink-0 text-center text-neutral-500 select-none"> 233 + <svg 234 + style="padding: 1px" 235 + xmlns="http://www.w3.org/2000/svg" 236 + height="22px" 237 + viewBox="0 0 24 24" 238 + width="22px" 239 + fill="currentColor" 240 + ><path d="M0 0h24v24H0V0z" fill="none" /><path 241 + 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" 242 + /></svg 243 + > 244 + </span> 245 + <div class="min-w-0 flex-1"> 246 + <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 247 + {tl.name} 248 + </p> 249 + <p class="mt-0.5 text-xs text-neutral-500"> 250 + {tl.children.length} 251 + {tl.children.length === 1 ? 'item' : 'items'} · {count_tracks_in(child_id)} tracks 252 + </p> 253 + </div> 254 + <span class="text-lg text-neutral-400 select-none dark:text-neutral-700">›</span> 255 + </button> 256 + </li> 257 + {:else if tl?.type === 'playlist'} 258 + <li> 259 + <button 260 + type="button" 261 + onclick={() => open_playlist(child_id)} 262 + class="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-neutral-100/50 active:bg-neutral-100 dark:hover:bg-neutral-800/50 dark:active:bg-neutral-800" 263 + > 264 + <span class="w-7 shrink-0 text-center text-neutral-500 select-none"> 265 + <svg 266 + style="transform: translateX(2px)" 267 + xmlns="http://www.w3.org/2000/svg" 268 + height="24px" 269 + viewBox="0 0 24 24" 270 + width="24px" 271 + fill="currentColor" 272 + ><path 273 + 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" 274 + /></svg 275 + > 276 + </span> 277 + <div class="min-w-0 flex-1"> 278 + <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 279 + {tl.name} 280 + </p> 281 + <p class="mt-0.5 text-xs text-neutral-500"> 282 + {tl.tracks.length} 283 + {tl.tracks.length === 1 ? 'track' : 'tracks'}{tl.liked ? ' · ♥' : ''} 284 + </p> 285 + </div> 286 + <span class="text-lg text-neutral-400 select-none dark:text-neutral-700">›</span> 287 + </button> 288 + </li> 289 + {/if} 290 + {/each} 291 + </ul> 292 + {:else} 293 + <div 294 + class="flex h-32 items-center justify-center text-xs text-neutral-400 dark:text-neutral-700" 295 + > 296 + This folder is empty 297 + </div> 298 + {/if} 299 + </div> 302 300 303 - <div class="flex items-center gap-1.5 px-4 pb-2 overflow-x-auto no-scrollbar"> 304 - <button type="button" onclick={() => active_filter = { kind: 'all' }} 305 - 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-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 306 - All 307 - </button> 308 - <button type="button" onclick={() => active_filter = { kind: 'liked' }} 309 - class="shrink-0 px-2.5 py-1 rounded-full text-xs border transition-colors {active_filter.kind === 'liked' ? 'bg-rose-100 dark:bg-rose-900 border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-200 font-semibold' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 310 - ♥ Liked 311 - </button> 312 - {#each genres as genre} 313 - <button type="button" onclick={() => active_filter = { kind: 'genre', value: genre }} 314 - 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-200 dark:bg-neutral-700 border-neutral-400 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 font-semibold' : 'border-neutral-300 dark:border-neutral-700 text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300'}"> 315 - {genre} 316 - </button> 317 - {/each} 318 - </div> 319 - </div> 301 + <!-- ── Tracks view ────────────────────────────────────────────────────── --> 302 + {:else if view.kind === 'tracks'} 303 + <div class="shrink-0 border-b border-neutral-200 dark:border-neutral-800"> 304 + <div class="no-scrollbar flex items-center gap-1.5 overflow-x-auto px-4 py-2"> 305 + <div class="relative shrink-0"> 306 + <span 307 + class="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-xs text-neutral-500 select-none" 308 + >⌕</span 309 + > 310 + <input 311 + type="search" 312 + placeholder="Search…" 313 + bind:value={search_query} 314 + class="w-32 rounded-lg border border-neutral-300 bg-neutral-100 py-1.5 pr-3 pl-7 text-xs text-neutral-800 placeholder-neutral-400 transition-colors outline-none focus:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:placeholder-neutral-600 dark:focus:border-neutral-500" 315 + /> 316 + </div> 317 + <div class="mx-0.5 h-4 w-px shrink-0 bg-neutral-100 dark:bg-neutral-800"></div> 318 + <button 319 + type="button" 320 + onclick={() => toggle_sort('name')} 321 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 'name' 322 + ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 323 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 324 + > 325 + Name{sort_indicator('name')} 326 + </button> 327 + <button 328 + type="button" 329 + onclick={() => toggle_sort('artist')} 330 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 331 + 'artist' 332 + ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 333 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 334 + > 335 + Artist{sort_indicator('artist')} 336 + </button> 337 + <button 338 + type="button" 339 + onclick={() => toggle_sort('dateAdded')} 340 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 341 + 'dateAdded' 342 + ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 343 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 344 + > 345 + Date{sort_indicator('dateAdded')} 346 + </button> 347 + <button 348 + type="button" 349 + onclick={() => toggle_sort('playCount')} 350 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 351 + 'playCount' 352 + ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 353 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 354 + > 355 + Plays{sort_indicator('playCount')} 356 + </button> 357 + <span 358 + class="ml-auto shrink-0 pl-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 359 + > 360 + {filtered_tracks.length}/{playlist_tracks.length} 361 + </span> 362 + </div> 320 363 321 - <div class="flex-1 overflow-y-auto"> 322 - {#if filtered_tracks.length > 0} 323 - <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 324 - {#each filtered_tracks as track} 325 - <li class="flex items-center gap-3 px-4 py-3 transition-colors"> 326 - <div class="flex-1 min-w-0"> 327 - <p class="font-medium text-neutral-900 dark:text-neutral-100 truncate">{track.name}</p> 328 - <p class="text-xs text-neutral-500 truncate mt-0.5"> 329 - {track.artist ?? 'Unknown Artist'} 330 - {#if track.albumName} 331 - <span class="text-neutral-400 dark:text-neutral-700"> · </span>{track.albumName} 332 - {/if} 333 - </p> 334 - <div class="flex items-center gap-2 mt-1"> 335 - {#if track.genre} 336 - <span class="px-1.5 py-px text-xs rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500">{track.genre}</span> 337 - {/if} 338 - {#if track.year} 339 - <span class="text-xs text-neutral-400 dark:text-neutral-700">{track.year}</span> 340 - {/if} 341 - </div> 342 - </div> 343 - <div class="shrink-0 flex flex-col items-end gap-1 text-xs text-neutral-400 dark:text-neutral-600 tabular-nums"> 344 - <span>{format_duration(track.duration)}</span> 345 - {#if track.playCount} 346 - <span>{track.playCount} plays</span> 347 - {/if} 348 - {#if track.liked} 349 - <span class="text-rose-500">♥</span> 350 - {/if} 351 - </div> 352 - </li> 353 - {/each} 354 - </ul> 355 - {:else} 356 - <div class="flex flex-col items-center justify-center h-full gap-3 text-neutral-400 dark:text-neutral-700 px-8 text-center"> 357 - <span class="text-4xl">⌕</span> 358 - <p class="text-xs">No tracks match your search or filter</p> 359 - </div> 360 - {/if} 361 - </div> 362 - {/if} 364 + <div class="no-scrollbar flex items-center gap-1.5 overflow-x-auto px-4 pb-2"> 365 + <button 366 + type="button" 367 + onclick={() => (active_filter = { kind: 'all' })} 368 + class="shrink-0 rounded-full border px-2.5 py-1 text-xs transition-colors {active_filter.kind === 369 + 'all' 370 + ? 'border-neutral-200 bg-neutral-200 font-semibold text-neutral-900' 371 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 372 + > 373 + All 374 + </button> 375 + <button 376 + type="button" 377 + onclick={() => (active_filter = { kind: 'liked' })} 378 + class="shrink-0 rounded-full border px-2.5 py-1 text-xs transition-colors {active_filter.kind === 379 + 'liked' 380 + ? 'border-rose-300 bg-rose-100 font-semibold text-rose-700 dark:border-rose-700 dark:bg-rose-900 dark:text-rose-200' 381 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 382 + > 383 + ♥ Liked 384 + </button> 385 + {#each genres as genre} 386 + <button 387 + type="button" 388 + onclick={() => (active_filter = { kind: 'genre', value: genre })} 389 + class="shrink-0 rounded-full border px-2.5 py-1 text-xs transition-colors {active_filter.kind === 390 + 'genre' && active_filter.value === genre 391 + ? 'border-neutral-400 bg-neutral-200 font-semibold text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 392 + : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 393 + > 394 + {genre} 395 + </button> 396 + {/each} 397 + </div> 398 + </div> 363 399 400 + <div class="flex-1 overflow-y-auto"> 401 + {#if filtered_tracks.length > 0} 402 + <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 403 + {#each filtered_tracks as track} 404 + <li class="flex items-center gap-3 px-4 py-3 transition-colors"> 405 + <div class="min-w-0 flex-1"> 406 + <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 407 + {track.name} 408 + </p> 409 + <p class="mt-0.5 truncate text-xs text-neutral-500"> 410 + {track.artist ?? 'Unknown Artist'} 411 + {#if track.albumName} 412 + <span class="text-neutral-400 dark:text-neutral-700"> · </span>{track.albumName} 413 + {/if} 414 + </p> 415 + <div class="mt-1 flex items-center gap-2"> 416 + {#if track.genre} 417 + <span 418 + class="rounded bg-neutral-100 px-1.5 py-px text-xs text-neutral-500 dark:bg-neutral-800" 419 + >{track.genre}</span 420 + > 421 + {/if} 422 + {#if track.year} 423 + <span class="text-xs text-neutral-400 dark:text-neutral-700">{track.year}</span> 424 + {/if} 425 + </div> 426 + </div> 427 + <div 428 + class="flex shrink-0 flex-col items-end gap-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 429 + > 430 + <span>{format_duration(track.duration)}</span> 431 + {#if track.playCount} 432 + <span>{track.playCount} plays</span> 433 + {/if} 434 + {#if track.liked} 435 + <span class="text-rose-500">♥</span> 436 + {/if} 437 + </div> 438 + </li> 439 + {/each} 440 + </ul> 441 + {:else} 442 + <div 443 + class="flex h-full flex-col items-center justify-center gap-3 px-8 text-center text-neutral-400 dark:text-neutral-700" 444 + > 445 + <span class="text-4xl">⌕</span> 446 + <p class="text-xs">No tracks match your search or filter</p> 447 + </div> 448 + {/if} 449 + </div> 450 + {/if} 364 451 </div> 365 452 366 453 <style> 367 - .no-scrollbar::-webkit-scrollbar { display: none; } 368 - .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } 454 + .no-scrollbar::-webkit-scrollbar { 455 + display: none; 456 + } 457 + .no-scrollbar { 458 + -ms-overflow-style: none; 459 + scrollbar-width: none; 460 + } 369 461 </style>