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

Filtering, sorting, playlists

Kasper (Mar 5, 2026, 1:28 PM +0100) e3dc36ce 94f1833a

+241 -31
+241 -31
mobile/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import commands from '$lib/commands' 3 3 import { open } from '@tauri-apps/plugin-dialog'; 4 - import type { Track } from '../../bindings' 5 - import { readTextFile } from '@tauri-apps/plugin-fs' 4 + import type { Track, TrackList, Playlist, LibraryTauri } from '../../bindings' 5 + import { readTextFile } from '@tauri-apps/plugin-fs' 6 + 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 }; 6 10 7 - let tracks: Track[] = []; 11 + let library: LibraryTauri | null = null; 8 12 let loading = false; 9 13 let error = ''; 10 - let msg = '' 14 + 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; 11 20 12 21 async function open_library() { 13 - msg = 'Loading...' 14 22 const path = await open({ 15 23 filters: [{ name: 'JSON', extensions: ['json'] }], 16 24 }); ··· 18 26 19 27 loading = true; 20 28 error = ''; 21 - const contents = await readTextFile(path); 29 + try { 30 + const contents = await readTextFile(path); 22 31 const result = await commands.loadLibrary(contents); 23 - msg = 'Loaded' + JSON.stringify(result) 24 32 if (result.status === 'ok') { 25 - tracks = result.data; 33 + library = result.data; 34 + selected_playlist_id = null; 35 + active_filter = { kind: 'all' }; 26 36 } else { 27 37 error = result.error; 28 38 } 39 + } catch (e) { 40 + error = e instanceof Error ? e.message : 'Failed to load library'; 41 + } finally { 42 + loading = false; 43 + } 29 44 } 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 + }); 30 112 </script> 31 113 32 - <main class="p-6"> 33 - <button 34 - type='button' 35 - onclick={open_library} 36 - disabled={loading} 37 - class="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50" 38 - > 39 - {loading ? 'Loading...' : 'Open Library'} 40 - </button> 114 + <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> 41 136 42 137 {#if error} 43 - <p class="mt-4 text-red-500">{error}</p> 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> 44 141 {/if} 45 142 46 - {#if tracks.length > 0} 47 - <p class="mt-4 text-sm text-gray-500">{tracks.length} tracks</p> 48 - <ul class="mt-2 divide-y divide-gray-200"> 49 - {#each tracks as track} 50 - <li class="py-3"> 51 - <p class="font-medium">{track.name}</p> 52 - {#if track.artist} 53 - <p class="text-sm text-gray-500">{track.artist}</p> 54 - {/if} 55 - </li> 56 - {/each} 57 - </ul> 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> 209 + 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 + 253 + {:else} 254 + <!-- Empty state --> 255 + <div class="flex flex-col items-center justify-center flex-1 gap-4 text-neutral-700 px-8 text-center"> 256 + <span class="text-5xl">♪</span> 257 + <div> 258 + <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> 260 + </div> 261 + </div> 58 262 {/if} 59 - </main> 263 + 264 + </div> 265 + 266 + <style> 267 + .no-scrollbar::-webkit-scrollbar { display: none; } 268 + .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } 269 + </style>