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

Mobile: Full-featured filtering, like on desktop

Kasper (Mar 10, 2026, 4:05 PM +0100) 79ea9724 779e48e0

+392 -307
+1
CHANGELOG.md
··· 7 7 - Keep scroll position when opening play history 8 8 - Mobile: Add track streaming search links for Spotify and YouTube Music 9 9 - Mobile: Fast playlist loading 10 + - Mobile: Add full-featured filtering like the desktop app has 10 11 11 12 ## 0.21.0 - 2026 Map 6 12 13 - Add a basic Android app. It only lets you browse a Ferrum Library.json file. No playback or syncing yet.
+20 -2
mobile/bindings.ts
··· 23 23 if(e instanceof Error) throw e; 24 24 else return { status: "error", error: e as any }; 25 25 } 26 + }, 27 + async getTrackByItemId(itemId: number) : Promise<Result<Track, string>> { 28 + try { 29 + return { status: "ok", data: await TAURI_INVOKE("get_track_by_item_id", { itemId }) }; 30 + } catch (e) { 31 + if(e instanceof Error) throw e; 32 + else return { status: "error", error: e as any }; 33 + } 34 + }, 35 + async getTracksPage(options: TracksPageOptions) : Promise<Result<TracksPage, string>> { 36 + try { 37 + return { status: "ok", data: await TAURI_INVOKE("get_tracks_page", { options }) }; 38 + } catch (e) { 39 + if(e instanceof Error) throw e; 40 + else return { status: "error", error: e as any }; 41 + } 26 42 } 27 43 } 28 44 ··· 46 62 * For example iTunes Persistent ID 47 63 */ 48 64 originalId?: string | null; dateImported?: string | null; dateCreated?: string | null; children: string[] } 49 - export type LibraryTauri = { tracks: Partial<{ [key in string]: Track }>; track_item_ids: Partial<{ [key in string]: number }>; track_lists: Partial<{ [key in string]: TrackList }> } 50 - export type Playlist = { id: string; name: string; description?: string | null; liked: boolean; disliked: boolean; importedFrom?: string | null; originalId?: string | null; dateImported?: string | null; dateCreated?: string | null; tracks: number[] } 65 + export type LibraryTauri = { track_lists: Partial<{ [key in string]: TrackList }>; song_count: string } 66 + export type Playlist = { id: string; name: string; description?: string | null; liked: boolean; disliked: boolean; importedFrom?: string | null; originalId?: string | null; dateImported?: string | null; dateCreated?: string | null; tracks: string[] } 51 67 export type Special = { id: string; name: SpecialTrackListName; dateCreated: string; children: string[] } 52 68 export type SpecialTrackListName = "Root" 53 69 export type Track = { size: string; duration: number; bitrate: number; sampleRate: number; file: string; dateModified: string; dateAdded: string; name: string; importedFrom?: string | null; ··· 60 76 */ 61 77 volume?: number | null } 62 78 export type TrackList = ({ type: "playlist" } & Playlist) | ({ type: "folder" } & Folder) | ({ type: "special" } & Special) 79 + export type TracksPage = { playlist_kind: string; playlist_name: string; playlist_description: string | null; playlist_length: number; item_ids: number[] } 80 + export type TracksPageOptions = { playlist_id: string; sort_key: string; sort_desc: boolean; filter_query: string; group_album_tracks: boolean } 63 81 64 82 /** tauri-specta globals **/ 65 83
+41 -9
mobile/src-tauri/src/lib.rs
··· 1 + use anyhow::Result; 1 2 use anyhow::bail; 2 - use ferrum::library_types::{ItemId, TrackID, VersionedLibrary}; 3 + use ferrum::library_types::TRACK_ID_MAP; 4 + use ferrum::library_types::VersionedLibrary; 3 5 use ferrum::library_types::{Library, Track, TrackList, TrackListID}; 6 + use ferrum::page::TracksPage; 7 + use ferrum::page::TracksPageOptions; 8 + use ferrum::page::get_tracks_page_from_library; 4 9 use serde::Serialize; 5 10 use specta::Type; 6 11 use std::collections::HashMap; 12 + use std::sync::Mutex; 7 13 use std::time::Instant; 14 + use tauri::AppHandle; 15 + use tauri::Manager; 8 16 use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 9 17 use tauri_specta::Builder; 10 18 ··· 23 31 24 32 #[tauri::command] 25 33 #[specta::specta] 26 - async fn open_file_persistent_android(app: tauri::AppHandle) -> Result<Option<String>, String> { 34 + async fn open_file_persistent_android(app: AppHandle) -> Result<Option<String>, String> { 27 35 if cfg!(not(target_os = "android")) { 28 36 panic!("Persistent save dialog cannot be called on this platform"); 29 37 } ··· 79 87 Ok(library) 80 88 } 81 89 90 + #[tauri::command] 91 + #[specta::specta] 92 + fn get_track_by_item_id(item_id: u32, app: AppHandle) -> Result<Track, String> { 93 + let library_state = app.state::<Mutex<Library>>(); 94 + let library = library_state.lock().unwrap(); 95 + let id_map = TRACK_ID_MAP.read().unwrap(); 96 + let track_id = &id_map[item_id as usize]; 97 + let track = library.get_track(&track_id).expect("Could not get track"); 98 + Ok(track.clone()) 99 + } 100 + 101 + #[tauri::command] 102 + #[specta::specta] 103 + fn get_tracks_page(options: TracksPageOptions, app: AppHandle) -> Result<TracksPage, String> { 104 + let library_state = app.state::<Mutex<Library>>(); 105 + let library = library_state.lock().unwrap(); 106 + match get_tracks_page_from_library(options, &library) { 107 + Ok(page) => Ok(page), 108 + Err(err) => Err(err.to_string()), 109 + } 110 + } 111 + 82 112 #[derive(Serialize, Type)] 83 113 pub struct LibraryTauri { 84 - tracks: HashMap<TrackID, Track>, 85 - track_item_ids: HashMap<TrackID, ItemId>, 86 114 track_lists: HashMap<TrackListID, TrackList>, 115 + song_count: usize, 87 116 } 88 117 89 118 #[tauri::command] 90 119 #[specta::specta] 91 - fn load_library(library_json: String) -> Result<LibraryTauri, String> { 120 + fn load_library(library_json: String, app: AppHandle) -> Result<LibraryTauri, String> { 92 121 let library = match load_library_from_file(&library_json) { 93 122 Ok(library) => library, 94 123 Err(err) => return Err(err.to_string()), 95 124 }; 96 125 97 126 let library_tauri = LibraryTauri { 98 - tracks: library.get_tracks().clone().into_iter().collect(), 99 - track_item_ids: library.get_track_item_ids().clone().into_iter().collect(), 100 - track_lists: library.trackLists.into_iter().collect(), 127 + track_lists: library.trackLists.clone().into_iter().collect(), 128 + song_count: library.get_track_item_ids().len(), 101 129 }; 102 130 131 + app.manage(Mutex::new(library)); 132 + 103 133 Ok(library_tauri) 104 134 } 105 135 ··· 108 138 tauri_specta::Builder::<tauri::Wry>::new().commands(tauri_specta::collect_commands![ 109 139 error_popup, 110 140 load_library, 111 - open_file_persistent_android 141 + open_file_persistent_android, 142 + get_track_by_item_id, 143 + get_tracks_page, 112 144 ]); 113 145 114 146 #[cfg(all(debug_assertions, not(target_os = "android")))]
+178 -152
mobile/src/routes/Library.svelte
··· 2 2 import type { Snippet } from 'svelte' 3 3 import { goto } from '$app/navigation' 4 4 import { page } from '$app/state' 5 - import type { Track, TrackList, Playlist, Folder, Special, LibraryTauri } from '../../bindings' 5 + import { 6 + type Track, 7 + type TrackList, 8 + type Folder, 9 + type Special, 10 + type LibraryTauri, 11 + type TracksPageOptions, 12 + } from '../../bindings' 6 13 import { resolve } from '$app/paths' 7 14 import { openUrl } from '@tauri-apps/plugin-opener' 15 + import VirtualListBlock from '../../../src/components/VirtualListBlock.svelte' 16 + import commands from '$lib/commands' 17 + import { error as sk_error } from '@sveltejs/kit' 8 18 9 - type sort_key_type = 'name' | 'artist' | 'dateAdded' | 'playCount' 10 - type sort_dir_type = 'asc' | 'desc' 11 - type active_filter_type = { kind: 'all' } | { kind: 'genre'; value: string } 12 19 type view_type = { kind: 'browser'; folder_id: string } | { kind: 'tracks'; playlist_id: string } 13 20 type streaming_service_type = 'spotify' | 'youtube-music' 14 21 22 + async function get_track(item_id: number) { 23 + const result = await commands.getTrackByItemId(item_id) 24 + if (result.status === 'ok') { 25 + return result.data 26 + } else { 27 + sk_error(500, result.error) 28 + } 29 + } 30 + 15 31 const { library, open_button, streaming_service } = $props<{ 16 32 library: LibraryTauri | null 17 33 open_button: Snippet<[]> 18 34 streaming_service: streaming_service_type 19 35 }>() 20 36 let error = $state('') 21 - let search_query = $state('') 22 - let sort_key = $state<sort_key_type>('dateAdded') 23 - let sort_dir = $state<sort_dir_type>('desc') 24 - let active_filter = $state<active_filter_type>({ kind: 'all' }) 37 + let tracks_page_options = $state<TracksPageOptions>({ 38 + playlist_id: 'root', 39 + sort_key: 'dateAdded', 40 + sort_desc: false, 41 + filter_query: '', 42 + group_album_tracks: false, 43 + }) 25 44 26 45 // ── View derived from URL search params ──────────────────────────────────── 27 46 const view = $derived<view_type>( ··· 29 48 ? { kind: 'tracks', playlist_id: page.url.searchParams.get('id') ?? 'root' } 30 49 : { kind: 'browser', folder_id: page.url.searchParams.get('id') ?? 'root' }, 31 50 ) 51 + $effect(() => { 52 + if (view.kind === 'tracks') { 53 + tracks_page_options.playlist_id = view.playlist_id 54 + } 55 + }) 32 56 33 57 // ── Helpers ──────────────────────────────────────────────────────────────── 34 58 ··· 46 70 return tl?.type === 'folder' ? tl : null 47 71 } 48 72 49 - function get_playlist(id: string): (Playlist & { type: 'playlist' }) | null { 50 - const tl = get_tracklist(id) 51 - return tl?.type === 'playlist' ? tl : null 73 + async function get_tracks_page(options: TracksPageOptions) { 74 + const result = await commands.getTracksPage(options) 75 + if (result.status === 'error') { 76 + sk_error(500, result.error) 77 + } 78 + return result.data 52 79 } 53 80 54 81 function get_children(folder_id: string): string[] { ··· 81 108 } 82 109 83 110 function open_playlist(id: string) { 84 - search_query = '' 85 - active_filter = { kind: 'all' } 111 + tracks_page_options.filter_query = '' 112 + tracks_page_options.filter_query = '' 86 113 // eslint-disable-next-line svelte/no-navigation-without-resolve 87 114 goto(resolve('/') + `?view=tracks&id=${id}`) 88 115 } ··· 91 118 92 119 const current_children = $derived(view.kind === 'browser' ? get_children(view.folder_id) : []) 93 120 94 - // Playlist.tracks is typed as number[] in the bindings, but serialize_playlist_ids 95 - // in Rust serializes them back to track ID strings over the wire. 96 - const playlist_tracks = $derived.by(() => { 97 - if (view.kind !== 'tracks') return [] 98 - // Fake "All Songs" playlist — aggregate every track in the library 99 - if (view.playlist_id === 'root') { 100 - return Object.values(library?.tracks ?? {}) as Track[] 101 - } 102 - const playlist = get_playlist(view.playlist_id) 103 - if (!playlist) { 104 - console.error('[Library] no playlist found for id', view.playlist_id) 105 - return [] 106 - } 107 - const track_ids = playlist.tracks as unknown as string[] 108 - const resolved = track_ids 109 - .map((track_id) => { 110 - const track = library?.tracks?.[track_id] 111 - if (track === undefined) console.error('[Library] no track for track_id', track_id) 112 - return track 113 - }) 114 - .filter((t): t is Track => t !== undefined) 115 - return resolved 121 + const tracks_page = $derived( 122 + view.kind === 'tracks' ? await get_tracks_page(tracks_page_options) : null, 123 + ) 124 + let item_ids: number[] = $state([]) 125 + $effect(() => { 126 + item_ids = tracks_page?.item_ids ?? [] 116 127 }) 117 128 118 - const genres = $derived( 119 - [ 120 - ...new Set( 121 - playlist_tracks 122 - .map((t) => t.genre) 123 - .filter((g): g is string => g !== null && g !== undefined), 124 - ), 125 - ].sort(), 126 - ) 129 + // const genres = $derived( 130 + // [ 131 + // ...new Set( 132 + // playlist_tracks 133 + // .map((t) => t.genre) 134 + // .filter((g): g is string => g !== null && g !== undefined), 135 + // ), 136 + // ].sort(), 137 + // ) 127 138 128 - const filtered_tracks = $derived( 129 - playlist_tracks 130 - .filter((t) => { 131 - if (active_filter.kind === 'genre') return t.genre === active_filter.value 132 - return true 133 - }) 134 - .filter((t) => { 135 - if (!search_query) return true 136 - const q = search_query.toLowerCase() 137 - return ( 138 - t.name.toLowerCase().includes(q) || 139 - (t.artist?.toLowerCase().includes(q) ?? false) || 140 - (t.albumName?.toLowerCase().includes(q) ?? false) 141 - ) 142 - }) 143 - .sort((a, b) => { 144 - let av: string | number 145 - let bv: string | number 146 - if (sort_key === 'name') { 147 - av = a.name 148 - bv = b.name 149 - } else if (sort_key === 'artist') { 150 - av = a.artist ?? '' 151 - bv = b.artist ?? '' 152 - } else if (sort_key === 'dateAdded') { 153 - av = a.dateAdded 154 - bv = b.dateAdded 155 - } else { 156 - av = a.playCount ?? 0 157 - bv = b.playCount ?? 0 158 - } 159 - if (av < bv) return sort_dir === 'asc' ? -1 : 1 160 - if (av > bv) return sort_dir === 'asc' ? 1 : -1 161 - return 0 162 - }), 163 - ) 139 + // const filtered_tracks = $derived( 140 + // playlist_tracks 141 + // .filter((t) => { 142 + // if (active_filter.kind === 'genre') return t.genre === active_filter.value 143 + // return true 144 + // }) 145 + // .filter((t) => { 146 + // if (!search_query) return true 147 + // const q = search_query.toLowerCase() 148 + // return ( 149 + // t.name.toLowerCase().includes(q) || 150 + // (t.artist?.toLowerCase().includes(q) ?? false) || 151 + // (t.albumName?.toLowerCase().includes(q) ?? false) 152 + // ) 153 + // }) 154 + // .sort((a, b) => { 155 + // let av: string | number 156 + // let bv: string | number 157 + // if (sort_key === 'name') { 158 + // av = a.name 159 + // bv = b.name 160 + // } else if (sort_key === 'artist') { 161 + // av = a.artist ?? '' 162 + // bv = b.artist ?? '' 163 + // } else if (sort_key === 'dateAdded') { 164 + // av = a.dateAdded 165 + // bv = b.dateAdded 166 + // } else { 167 + // av = a.playCount ?? 0 168 + // bv = b.playCount ?? 0 169 + // } 170 + // if (av < bv) return sort_dir === 'asc' ? -1 : 1 171 + // if (av > bv) return sort_dir === 'asc' ? 1 : -1 172 + // return 0 173 + // }), 174 + // ) 175 + 176 + let scroll_container: HTMLElement | undefined = $state() 164 177 165 178 // ── Formatting ───────────────────────────────────────────────────────────── 166 179 167 - function toggle_sort(key: sort_key_type) { 168 - if (sort_key === key) sort_dir = sort_dir === 'asc' ? 'desc' : 'asc' 169 - else { 170 - sort_key = key 171 - sort_dir = 'asc' 180 + function toggle_sort(key: string, default_desc = false) { 181 + if (tracks_page_options.sort_key === key) { 182 + tracks_page_options.sort_desc = !tracks_page_options.sort_desc 183 + } else { 184 + tracks_page_options.sort_key = key 185 + tracks_page_options.sort_desc = default_desc 172 186 } 173 187 } 174 188 175 - function sort_indicator(key: sort_key_type): string { 176 - if (sort_key !== key) return '' 177 - return sort_dir === 'asc' ? ' ↑' : ' ↓' 189 + function sort_indicator(key: string): string { 190 + if (tracks_page_options.sort_key !== key) return '' 191 + return tracks_page_options.sort_desc === false ? ' ↑' : ' ↓' 178 192 } 179 193 180 194 function format_duration(seconds: number): string { ··· 264 278 All Songs 265 279 </p> 266 280 <p class="mt-0.5 text-xs text-neutral-500"> 267 - {Object.keys(library?.tracks ?? {}).length} 268 - {Object.keys(library?.tracks ?? {}).length === 1 ? 'track' : 'tracks'} 281 + {library?.song_count ?? 0} 282 + {(library?.song_count ?? 0) === 1 ? 'track' : 'tracks'} 269 283 </p> 270 284 </div> 271 285 <span class="text-lg text-neutral-400 select-none dark:text-neutral-700">›</span> ··· 362 376 <input 363 377 type="search" 364 378 placeholder="Search…" 365 - bind:value={search_query} 379 + bind:value={tracks_page_options.filter_query} 366 380 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" 367 381 /> 368 382 </div> ··· 370 384 <button 371 385 type="button" 372 386 onclick={() => toggle_sort('name')} 373 - class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 'name' 387 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {tracks_page_options.sort_key === 388 + 'name' 374 389 ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 375 390 : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 376 391 > ··· 379 394 <button 380 395 type="button" 381 396 onclick={() => toggle_sort('artist')} 382 - class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 397 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {tracks_page_options.sort_key === 383 398 'artist' 384 399 ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 385 400 : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" ··· 388 403 </button> 389 404 <button 390 405 type="button" 391 - onclick={() => toggle_sort('dateAdded')} 392 - class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 406 + onclick={() => toggle_sort('dateAdded', true)} 407 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {tracks_page_options.sort_key === 393 408 'dateAdded' 394 409 ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 395 410 : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 396 411 > 397 - Date{sort_indicator('dateAdded')} 412 + Date Added{sort_indicator('dateAdded')} 398 413 </button> 399 414 <button 400 415 type="button" 401 - onclick={() => toggle_sort('playCount')} 402 - class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {sort_key === 416 + onclick={() => toggle_sort('playCount', true)} 417 + class="shrink-0 rounded border px-2.5 py-1 text-xs transition-colors {tracks_page_options.sort_key === 403 418 'playCount' 404 419 ? 'border-neutral-400 bg-neutral-200 text-neutral-900 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-100' 405 420 : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" ··· 409 424 <span 410 425 class="ml-auto shrink-0 pl-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 411 426 > 412 - {filtered_tracks.length}/{playlist_tracks.length} 427 + {tracks_page?.item_ids.length}/{tracks_page?.playlist_length} 413 428 </span> 414 429 </div> 415 430 416 431 <div class="no-scrollbar flex items-center gap-1.5 overflow-x-auto px-4 pb-2"> 417 - <button 432 + <!-- <button 418 433 type="button" 419 - onclick={() => (active_filter = { kind: 'all' })} 420 - class="shrink-0 rounded-full border px-2.5 py-1 text-xs transition-colors {active_filter.kind === 421 - 'all' 434 + onclick={() => (tracks_page_options.filter_query = '')} 435 + class="shrink-0 rounded-full border px-2.5 py-1 text-xs transition-colors {tracks_page_options 436 + .filter_query.kind === 'all' 422 437 ? 'border-neutral-200 bg-neutral-200 font-semibold text-neutral-900' 423 438 : 'border-neutral-300 text-neutral-500 hover:text-neutral-600 dark:border-neutral-700 dark:hover:text-neutral-300'}" 424 439 > 425 440 All 426 - </button> 427 - {#each genres as genre} 441 + </button> --> 442 + <!-- {#each genres as genre} 428 443 <button 429 444 type="button" 430 445 onclick={() => (active_filter = { kind: 'genre', value: genre })} ··· 435 450 > 436 451 {genre} 437 452 </button> 438 - {/each} 453 + {/each} --> 439 454 </div> 440 455 </div> 441 456 442 - <div class="flex-1 overflow-y-auto"> 443 - {#if filtered_tracks.length > 0} 457 + <div class="flex-1 overflow-y-auto" bind:this={scroll_container}> 458 + {#if tracks_page && tracks_page.item_ids.length > 0} 444 459 <ul class="divide-y divide-neutral-200 dark:divide-neutral-900"> 445 - {#each filtered_tracks as track} 446 - <li> 447 - <button 448 - type="button" 449 - class="focus-visible active flex w-full items-center justify-between gap-3 px-4 py-3 focus-visible:bg-neutral-100" 450 - onclick={() => open_track(track)} 451 - > 452 - <div class="grow text-left"> 453 - <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 454 - {track.name} 455 - </p> 456 - <p class="mt-0.5 truncate text-xs text-neutral-500"> 457 - {track.artist ?? 'Unknown Artist'} 458 - {#if track.albumName} 459 - <span class="text-neutral-400 dark:text-neutral-700"> 460 - · 461 - </span>{track.albumName} 462 - {/if} 463 - </p> 464 - <div class="mt-1 flex items-center gap-2"> 465 - {#if track.genre} 466 - <span 467 - class="rounded bg-neutral-100 px-1.5 py-px text-xs text-neutral-500 dark:bg-neutral-800" 468 - >{track.genre}</span 469 - > 470 - {/if} 471 - {#if track.year} 472 - <span class="text-xs text-neutral-400 dark:text-neutral-700" 473 - >{track.year}</span 474 - > 475 - {/if} 476 - </div> 477 - </div> 478 - <div 479 - class="flex shrink-0 flex-col items-end gap-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 480 - > 481 - <span>{format_duration(track.duration)}</span> 482 - {#if track.playCount} 483 - <span>{track.playCount} plays</span> 484 - {/if} 485 - </div> 486 - </button> 487 - </li> 488 - {/each} 460 + <VirtualListBlock 461 + buffer={10} 462 + items={item_ids} 463 + get_key={(item) => item} 464 + item_height={84} 465 + {scroll_container} 466 + > 467 + {#snippet children({ item })} 468 + {#if item} 469 + {@const track = await get_track(item)} 470 + <li> 471 + <button 472 + type="button" 473 + class="focus-visible active flex w-full items-center justify-between gap-3 px-4 py-3 focus-visible:bg-neutral-100" 474 + onclick={() => open_track(track)} 475 + > 476 + <div class="grow text-left"> 477 + <p class="truncate font-medium text-neutral-900 dark:text-neutral-100"> 478 + {track.name} 479 + </p> 480 + <p class="mt-0.5 truncate text-xs text-neutral-500"> 481 + {track.artist ?? 'Unknown Artist'} 482 + {#if track.albumName} 483 + <span class="text-neutral-400 dark:text-neutral-700"> 484 + · 485 + </span>{track.albumName} 486 + {/if} 487 + </p> 488 + <div class="mt-1 flex items-center gap-2"> 489 + {#if track.genre} 490 + <span 491 + class="rounded bg-neutral-100 px-1.5 py-px text-xs text-neutral-500 dark:bg-neutral-800" 492 + >{track.genre}</span 493 + > 494 + {/if} 495 + {#if track.year} 496 + <span class="text-xs text-neutral-400 dark:text-neutral-700" 497 + >{track.year}</span 498 + > 499 + {/if} 500 + </div> 501 + </div> 502 + <div 503 + class="flex shrink-0 flex-col items-end gap-1 text-xs text-neutral-400 tabular-nums dark:text-neutral-600" 504 + > 505 + <span>{format_duration(track.duration)}</span> 506 + {#if track.playCount} 507 + <span>{track.playCount} plays</span> 508 + {/if} 509 + </div> 510 + </button> 511 + </li> 512 + {/if} 513 + {/snippet} 514 + </VirtualListBlock> 489 515 </ul> 490 516 {:else} 491 517 <div
+4 -7
src-native/lib.rs
··· 21 21 mod data; 22 22 #[cfg(feature = "napi-rs")] 23 23 mod data_js; 24 - #[cfg(feature = "napi-rs")] 25 - mod filter; 24 + pub mod filter; 26 25 #[cfg(feature = "napi-rs")] 27 26 mod itunes_import; 28 27 pub mod library; 29 28 pub mod library_types; 29 + pub mod page; 30 30 #[cfg(feature = "napi-rs")] 31 - mod page; 32 - #[cfg(feature = "napi-rs")] 33 - mod playlists; 31 + pub mod playlists; 34 32 #[cfg(feature = "napi-rs")] 35 33 mod queue_state; 36 - #[cfg(feature = "napi-rs")] 37 - mod sort; 34 + pub mod sort; 38 35 #[cfg(feature = "napi-rs")] 39 36 mod tracks; 40 37 #[cfg(feature = "napi-rs")]
+24 -1
src-native/library.rs
··· 2 2 use crate::data::Data; 3 3 #[cfg(feature = "napi-rs")] 4 4 use crate::data_js::get_data; 5 - use crate::library_types::{Library, VersionedLibrary}; 5 + use crate::library_types::{ItemId, Library, SpecialTrackListName, TrackList, VersionedLibrary}; 6 6 use anyhow::{Context, Result, bail}; 7 + use linked_hash_map::LinkedHashMap; 7 8 #[cfg(feature = "napi-rs")] 8 9 use napi::Env; 9 10 use serde_json::{Value, json}; ··· 207 208 let genres = data.library.get_artists(); 208 209 genres.clone() 209 210 } 211 + 212 + pub fn get_tracklist_item_ids(library: &Library, playlist_id: &str) -> Result<Vec<ItemId>> { 213 + match library.get_tracklist(playlist_id)? { 214 + TrackList::Playlist(playlist) => Ok(playlist.tracks.clone()), 215 + TrackList::Folder(folder) => { 216 + let mut ids: LinkedHashMap<ItemId, ()> = LinkedHashMap::new(); 217 + for child in &folder.children { 218 + let child_ids = get_tracklist_item_ids(library, &child)?; 219 + for child_id in child_ids { 220 + ids.insert(child_id, ()); 221 + } 222 + } 223 + Ok(ids.into_iter().map(|(id, _)| id).collect()) 224 + } 225 + TrackList::Special(special) => match special.name { 226 + SpecialTrackListName::Root => { 227 + let item_ids = library.get_track_item_ids().values().cloned().collect(); 228 + Ok(item_ids) 229 + } 230 + }, 231 + } 232 + }
+1 -1
src-native/library_types.rs
··· 474 474 serialize_with = "serialize_playlist_ids" 475 475 )] 476 476 #[cfg_attr(feature = "napi", napi(ts_type = "string[]"))] 477 + #[specta(type = Vec<String>)] 477 478 pub tracks: Vec<ItemId>, 478 479 } 479 480 impl Playlist { ··· 537 538 } 538 539 539 540 #[derive(Serialize, Deserialize, Clone, Debug, Type)] 540 - #[non_exhaustive] 541 541 #[cfg_attr(feature = "napi-rs", napi)] 542 542 pub enum SpecialTrackListName { 543 543 Root,
+22 -8
src-native/page.rs
··· 1 + #[cfg(feature = "napi-rs")] 1 2 use crate::data::Data; 3 + #[cfg(feature = "napi-rs")] 2 4 use crate::data_js::get_data; 3 5 use crate::filter::filter; 4 - use crate::library_types::{ItemId, TrackList}; 6 + use crate::library_types::{ItemId, Library, TrackList}; 5 7 use crate::sort::sort; 8 + #[cfg(feature = "napi-rs")] 6 9 use napi::{Env, Result}; 10 + use serde::{Deserialize, Serialize}; 11 + use specta::Type; 7 12 8 - #[napi(object)] 9 - #[derive(Clone)] 13 + #[cfg_attr(feature = "napi", napi(object))] 14 + #[derive(Deserialize, Clone, Type)] 10 15 pub struct TracksPageOptions { 11 16 pub playlist_id: String, 12 17 pub sort_key: String, ··· 15 20 pub group_album_tracks: bool, 16 21 } 17 22 18 - #[napi(object)] 23 + #[cfg_attr(feature = "napi", napi(object))] 24 + #[derive(Serialize, Type)] 19 25 pub struct TracksPage { 20 26 pub playlist_kind: String, 21 27 pub playlist_name: String, ··· 24 30 pub item_ids: Vec<ItemId>, 25 31 } 26 32 27 - #[napi(js_name = "get_tracks_page")] 33 + #[cfg(feature = "napi-rs")] 34 + #[cfg_attr(feature = "napi", napi(js_name = "get_tracks_page"))] 28 35 #[allow(dead_code)] 29 36 pub fn get_tracks_page(options: TracksPageOptions, env: Env) -> Result<TracksPage> { 30 37 let data: &mut Data = get_data(&env); 31 - let tracklist = data.library.get_tracklist(&options.playlist_id)?; 32 - let item_ids = sort(options.clone(), &data.library)?; 38 + Ok(get_tracks_page_from_library(options, &data.library)?) 39 + } 40 + 41 + pub fn get_tracks_page_from_library( 42 + options: TracksPageOptions, 43 + library: &Library, 44 + ) -> anyhow::Result<TracksPage> { 45 + let tracklist = library.get_tracklist(&options.playlist_id)?; 46 + let item_ids = sort(options.clone(), &library)?; 33 47 let tracklist_length = item_ids.len(); 34 - let item_ids = filter(item_ids, options.filter_query, &data.library); 48 + let item_ids = filter(item_ids, options.filter_query, &library); 35 49 let track_page = match tracklist { 36 50 TrackList::Playlist(playlist) => TracksPage { 37 51 playlist_kind: tracklist.kind().to_string(),
-23
src-native/playlists.rs
··· 6 6 }; 7 7 use crate::str_to_option; 8 8 use anyhow::{Context, Result, bail}; 9 - use linked_hash_map::LinkedHashMap; 10 9 use napi::{Env, Unknown}; 11 10 use rayon::prelude::*; 12 11 use std::collections::{HashMap, HashSet}; ··· 446 445 playlist.tracks = start_items; 447 446 Ok(()) 448 447 } 449 - 450 - pub fn get_tracklist_item_ids(library: &Library, playlist_id: &str) -> Result<Vec<ItemId>> { 451 - match library.get_tracklist(playlist_id)? { 452 - TrackList::Playlist(playlist) => Ok(playlist.tracks.clone()), 453 - TrackList::Folder(folder) => { 454 - let mut ids: LinkedHashMap<ItemId, ()> = LinkedHashMap::new(); 455 - for child in &folder.children { 456 - let child_ids = get_tracklist_item_ids(library, &child)?; 457 - for child_id in child_ids { 458 - ids.insert(child_id, ()); 459 - } 460 - } 461 - Ok(ids.into_iter().map(|(id, _)| id).collect()) 462 - } 463 - TrackList::Special(special) => match special.name { 464 - SpecialTrackListName::Root => { 465 - let item_ids = library.get_track_item_ids().values().cloned().collect(); 466 - Ok(item_ids) 467 - } 468 - }, 469 - } 470 - }
+3 -2
src-native/sort.rs
··· 1 - use crate::library::{TrackField, get_track_field_type}; 1 + use crate::library::{TrackField, get_track_field_type, get_tracklist_item_ids}; 2 2 use crate::library_types::{ItemId, Library, TRACK_ID_MAP, Track}; 3 3 use crate::page::TracksPageOptions; 4 - use crate::playlists::get_tracklist_item_ids; 5 4 use alphanumeric_sort::compare_str; 6 5 use anyhow::{Context, Result}; 7 6 use std::cmp::Ordering; 8 7 use std::time::Instant; 8 + 9 + pub type TracksPageOptionsX = TracksPageOptions; 9 10 10 11 fn get_field_str<'a>(track: &'a Track, sort_key: &str) -> Option<&'a String> { 11 12 match sort_key {
+62 -62
src/components/Queue.svelte
··· 264 264 get_key={(item) => item.qId} 265 265 item_height={entry_height} 266 266 scroll_container={queue_element} 267 - let:item 268 - let:i={qi} 269 267 > 270 - <!-- svelte-ignore a11y-click-events-have-key-events --> 271 - <!-- svelte-ignore a11y-interactive-supports-focus --> 272 - <div 273 - class="row" 274 - role="row" 275 - class:selected={$selection.has(item.qId)} 276 - on:mousedown={(e) => selection.handle_mousedown(e, qi)} 277 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi)} 278 - on:click={(e) => selection.handle_click(e, qi)} 279 - draggable="true" 280 - on:dragstart={on_drag_start} 281 - on:dragover={(e) => on_drag_over(e, qi)} 282 - on:drop={drop_handler} 283 - on:dragleave={drag_end_handler} 284 - on:dragend={drag_end_handler} 285 - > 286 - <QueueItemComponent id={item.id} /> 287 - </div> 268 + {#snippet children({ item, i: qi })} 269 + <!-- svelte-ignore a11y-click-events-have-key-events --> 270 + <!-- svelte-ignore a11y-interactive-supports-focus --> 271 + <div 272 + class="row" 273 + role="row" 274 + class:selected={$selection.has(item.qId)} 275 + on:mousedown={(e) => selection.handle_mousedown(e, qi)} 276 + on:contextmenu={(e) => selection.handle_contextmenu(e, qi)} 277 + on:click={(e) => selection.handle_click(e, qi)} 278 + draggable="true" 279 + on:dragstart={on_drag_start} 280 + on:dragover={(e) => on_drag_over(e, qi)} 281 + on:drop={drop_handler} 282 + on:dragleave={drag_end_handler} 283 + on:dragend={drag_end_handler} 284 + > 285 + <QueueItemComponent id={item.id} /> 286 + </div> 287 + {/snippet} 288 288 </VirtualListBlock> 289 289 {#if $queue.current} 290 290 {@const qi = current_index} ··· 347 347 get_key={(item) => item.qId} 348 348 item_height={entry_height} 349 349 scroll_container={queue_element} 350 - let:item 351 - let:i 352 350 > 353 - {@const qi = i + up_next_index} 354 - <!-- svelte-ignore a11y-click-events-have-key-events --> 355 - <!-- svelte-ignore a11y-interactive-supports-focus --> 356 - <div 357 - class="row" 358 - role="row" 359 - class:selected={$selection.has(item.qId)} 360 - on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 361 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 362 - on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 363 - draggable="true" 364 - on:dragstart={on_drag_start} 365 - on:dragover={(e) => on_drag_over(e, qi)} 366 - on:drop={drop_handler} 367 - on:dragleave={drag_end_handler} 368 - on:dragend={drag_end_handler} 369 - > 370 - <QueueItemComponent id={item.id} /> 371 - </div> 351 + {#snippet children({ item, i })} 352 + {@const qi = i + up_next_index} 353 + <!-- svelte-ignore a11y-click-events-have-key-events --> 354 + <!-- svelte-ignore a11y-interactive-supports-focus --> 355 + <div 356 + class="row" 357 + role="row" 358 + class:selected={$selection.has(item.qId)} 359 + on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 360 + on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 361 + on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 362 + draggable="true" 363 + on:dragstart={on_drag_start} 364 + on:dragover={(e) => on_drag_over(e, qi)} 365 + on:drop={drop_handler} 366 + on:dragleave={drag_end_handler} 367 + on:dragend={drag_end_handler} 368 + > 369 + <QueueItemComponent id={item.id} /> 370 + </div> 371 + {/snippet} 372 372 </VirtualListBlock> 373 373 </div> 374 374 {/if} ··· 388 388 get_key={(item) => item.qId} 389 389 item_height={entry_height} 390 390 scroll_container={queue_element} 391 - let:item 392 - let:i 393 391 > 394 - {@const qi = i + autoplay_index} 395 - <!-- svelte-ignore a11y-click-events-have-key-events --> 396 - <!-- svelte-ignore a11y-interactive-supports-focus --> 397 - <div 398 - class="row" 399 - role="row" 400 - class:selected={$selection.has(item.qId)} 401 - on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 402 - on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 403 - on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 404 - draggable="true" 405 - on:dragstart={on_drag_start} 406 - on:dragover={(e) => on_drag_over(e, qi)} 407 - on:drop={drop_handler} 408 - on:dragleave={drag_end_handler} 409 - on:dragend={drag_end_handler} 410 - > 411 - <QueueItemComponent id={item.id} /> 412 - </div> 392 + {#snippet children({ item, i })} 393 + {@const qi = i + autoplay_index} 394 + <!-- svelte-ignore a11y-click-events-have-key-events --> 395 + <!-- svelte-ignore a11y-interactive-supports-focus --> 396 + <div 397 + class="row" 398 + role="row" 399 + class:selected={$selection.has(item.qId)} 400 + on:mousedown={(e) => selection.handle_mousedown(e, qi - first_visible_index)} 401 + on:contextmenu={(e) => selection.handle_contextmenu(e, qi - first_visible_index)} 402 + on:click={(e) => selection.handle_click(e, qi - first_visible_index)} 403 + draggable="true" 404 + on:dragstart={on_drag_start} 405 + on:dragover={(e) => on_drag_over(e, qi)} 406 + on:drop={drop_handler} 407 + on:dragleave={drag_end_handler} 408 + on:dragend={drag_end_handler} 409 + > 410 + <QueueItemComponent id={item.id} /> 411 + </div> 412 + {/snippet} 413 413 </VirtualListBlock> 414 414 </div> 415 415 {/if}
+36 -40
src/components/VirtualListBlock.svelte
··· 1 - <script lang="ts" context="module"> 1 + <script lang="ts" module> 2 2 export function scroll_container_keydown(e: KeyboardEvent & { currentTarget: HTMLElement }) { 3 3 let prevent = true 4 4 if (e.key === 'Home') e.currentTarget.scrollTop = 0 ··· 11 11 </script> 12 12 13 13 <script lang="ts" generics="T"> 14 - import { onDestroy } from 'svelte' 14 + import { onDestroy, type Snippet } from 'svelte' 15 15 16 - export let items: T[] 17 - export let item_height: number 18 - /** Must be a positioned element, like `position: relative` */ 19 - export let scroll_container: HTMLElement 20 - export let get_key: (item: T, i: number) => number | string 21 - export let buffer = 3 16 + type Props = { 17 + items: T[] 18 + item_height: number 19 + /** Must be a positioned element, like `position: relative` */ 20 + scroll_container: HTMLElement 21 + get_key: (item: T, i: number) => number | string 22 + buffer?: number 23 + children: Snippet<[{ item: T; i: number }]> 24 + } 22 25 23 - $: height = items.length * item_height 24 - $: buffer_height = buffer * item_height 26 + let { items, item_height, scroll_container, get_key, buffer = 3, children }: Props = $props() 25 27 26 - let main_element: HTMLDivElement 27 - let start_pixel = 0 28 - let start_index = 0 29 - let visible_count = 0 28 + const height = $derived(items.length * item_height) 29 + const buffer_height = $derived(buffer * item_height) 30 30 31 - // Workaround for svelte not updating the indexes when the keys change 32 - let visible_count_obj = { length: visible_count } 33 - $: visible_count_obj = { length: visible_count } 31 + let main_element: HTMLDivElement = $state()! 32 + let start_pixel = $state(0) 33 + let start_index = $state(0) 34 + let visible_count = $state(0) 34 35 35 - $: { 36 - ;(items, item_height, buffer) 36 + // Replaces the visible_count_obj workaround — $derived array forces key recalculation 37 + const visible_indices = $derived.by(() => { 38 + // Recompute synchronously when items changes, before refresh() fires 39 + const max = items.length 40 + const end = Math.min(start_index + visible_count, max) 41 + return Array.from({ length: Math.max(0, end - start_index) }, (_, i) => i + start_index) 42 + }) 43 + $effect(() => { 37 44 if (scroll_container && main_element) refresh() 38 - } 45 + }) 39 46 40 - const resize_observer = new ResizeObserver(refresh) 41 - $: observe(scroll_container) 42 - function observe(scroll_container: HTMLElement | undefined) { 47 + $effect(() => { 48 + const resize_observer = new ResizeObserver(refresh) 43 49 resize_observer.disconnect() 44 50 if (scroll_container) { 45 51 resize_observer.observe(scroll_container) 46 52 } 47 - } 53 + }) 48 54 49 55 export function refresh() { 50 - if (!main_element || !scroll_container) { 51 - return 52 - } 56 + if (!main_element || !scroll_container) return 53 57 54 58 let element_top = main_element.offsetTop 55 59 let offset_parent = main_element.offsetParent ··· 72 76 73 77 start_index = Math.floor(start_pixel / item_height) 74 78 visible_count = Math.ceil(end_pixel / item_height) - start_index 75 - visible_count_obj = { length: visible_count } 76 79 } 77 80 78 - $: apply_scroll_event_handler(scroll_container) 79 - 80 - let scroll_event_element: HTMLElement | undefined = scroll_container 81 - function apply_scroll_event_handler(container: HTMLElement | undefined) { 82 - scroll_event_element?.removeEventListener('scroll', refresh) 83 - scroll_event_element = container 84 - scroll_event_element?.addEventListener('scroll', refresh) 85 - } 86 - onDestroy(() => { 87 - scroll_event_element?.removeEventListener('scroll', refresh) 81 + $effect(() => { 82 + scroll_container?.addEventListener('scroll', refresh) 83 + return () => scroll_container?.removeEventListener('scroll', refresh) 88 84 }) 89 85 90 86 export function scroll_to_index(index: number, scroll_margin_bottom = 0) { ··· 106 102 style:padding-top={start_index * item_height + 'px'} 107 103 style:height={items.length * item_height + 'px'} 108 104 > 109 - {#each visible_count_obj as _, i (get_key(items[i + start_index], i + start_index))} 110 - <slot item={items[i + start_index]} i={i + start_index} /> 105 + {#each visible_indices as i (get_key(items[i], i))} 106 + {@render children({ item: items[i], i })} 111 107 {/each} 112 108 </div>