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

Add lyrics support

Kasper (Mar 8, 2026, 12:27 PM +0100) 8404e1c7 9f1ae7b6

+353 -12
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 ## Next 4 + - Add lyrics support 4 5 - Add "Show in Playlist" context menu item 5 6 - Remember queue on restart 6 7 - Keep scroll position when opening play history
+11 -10
src/App.svelte
··· 5 5 import Player from './components/Player.svelte' 6 6 import Sidebar from './components/Sidebar.svelte' 7 7 import Queue from './components/Queue.svelte' 8 + import Lyrics from './components/Lyrics.svelte' 8 9 import TrackInfo, { track_info_state } from './components/TrackInfo.svelte' 9 10 import PlaylistInfoModal from './components/PlaylistInfo.svelte' 10 11 import { queue_visible } from './lib/queue' 12 + import { lyrics_state } from '$lib/lyrics.svelte' 11 13 import { ipc_listen, ipc_renderer } from '$lib/window' 12 14 import { delete_track_list, get_track_list, import_tracks, type PlaylistInfo } from '$lib/data' 13 - import { play_pause } from './lib/player' 15 + import { play_pause, playing_track, time_record } from './lib/player' 14 16 import DragGhost from './components/DragGhost.svelte' 15 17 import ItunesImport from './components/ItunesImport.svelte' 16 18 import { modal_count } from './components/Modal.svelte' ··· 55 57 ipc_renderer.on('import', open_import_dialog) 56 58 onDestroy(() => { 57 59 ipc_renderer.removeListener('import', open_import_dialog) 58 - }) 59 - 60 - function toggle_queue() { 61 - $queue_visible = !$queue_visible 62 - } 63 - $: ipc_renderer.invoke('update:Show Queue', $queue_visible) 64 - ipc_renderer.on('Show Queue', toggle_queue) 65 - onDestroy(() => { 66 - ipc_renderer.removeListener('Show Queue', toggle_queue) 67 60 }) 68 61 69 62 function toggle_visualizer() { ··· 284 277 </div> 285 278 {#if $queue_visible} 286 279 <Queue /> 280 + {:else if lyrics_state.visible} 281 + <Lyrics 282 + track_name={$playing_track?.name || ''} 283 + artist_name={$playing_track?.artist || ''} 284 + album_name={$playing_track?.albumName || ''} 285 + duration_sec={$time_record.duration || null} 286 + current_time_sec={$time_record.elapsed || 0} 287 + /> 287 288 {/if} 288 289 </div> 289 290 <Player on_toggle_visualizer={toggle_visualizer} />
+249
src/components/Lyrics.svelte
··· 1 + <script lang="ts"> 2 + import { fly } from 'svelte/transition' 3 + 4 + type LrcLine = { 5 + time_sec: number 6 + text: string 7 + } 8 + 9 + type LrclibApiTrack = { 10 + plainLyrics?: string | null 11 + syncedLyrics?: string | null 12 + instrumental?: boolean 13 + } 14 + 15 + type LrclibTrack = { 16 + plain_lyrics: string | null 17 + synced_lyrics: string | null 18 + instrumental: boolean 19 + } 20 + 21 + type Props = { 22 + track_name?: string 23 + artist_name?: string 24 + album_name?: string 25 + duration_sec?: number | null 26 + current_time_sec?: number 27 + } 28 + 29 + const cache_by_key: Record<string, LrclibTrack | null> = {} 30 + 31 + let { 32 + track_name = '', 33 + artist_name = '', 34 + album_name = '', 35 + duration_sec = null, 36 + current_time_sec = 0, 37 + }: Props = $props() 38 + 39 + let loading = $state(false) 40 + let error_message = $state('') 41 + let lyrics_data = $state<LrclibTrack | null>(null) 42 + let request_key = $state('') 43 + 44 + const query_key = $derived(make_key(track_name, artist_name, album_name, duration_sec)) 45 + const synced_lines = $derived(parse_synced_lyrics(lyrics_data?.synced_lyrics ?? '')) 46 + const active_line_index = $derived(find_active_line_index(synced_lines, current_time_sec)) 47 + const plain_lines = $derived( 48 + (lyrics_data?.plain_lyrics ?? '') 49 + .split('\n') 50 + .map((line) => line.trim()) 51 + .filter(Boolean), 52 + ) 53 + 54 + $effect(() => { 55 + const key = query_key 56 + if (!key) { 57 + lyrics_data = null 58 + error_message = '' 59 + loading = false 60 + return 61 + } 62 + void load_lyrics(key, { 63 + track_name, 64 + artist_name, 65 + album_name, 66 + duration_sec, 67 + }) 68 + }) 69 + 70 + function normalize(value: string) { 71 + return value.trim().toLowerCase() 72 + } 73 + 74 + function make_key(track: string, artist: string, album: string, dur: number | null) { 75 + const t = normalize(track) 76 + const a = normalize(artist) 77 + if (!t || !a) return '' 78 + const alb = normalize(album) 79 + const d = typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : 0 80 + return `${a}|${t}|${alb}|${d}` 81 + } 82 + 83 + function encode_query(params: Record<string, string | number | undefined>) { 84 + const parts: string[] = [] 85 + for (const [key, value] of Object.entries(params)) { 86 + if (value === undefined) continue 87 + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) 88 + } 89 + return parts.join('&') 90 + } 91 + 92 + async function get_json(url: string) { 93 + const res = await fetch(url) 94 + if (res.status === 404) return null 95 + if (!res.ok) throw new Error(`LRCLIB request failed (${res.status})`) 96 + return await res.json() 97 + } 98 + 99 + function normalize_track(raw: LrclibApiTrack): LrclibTrack { 100 + return { 101 + plain_lyrics: raw.plainLyrics ?? null, 102 + synced_lyrics: raw.syncedLyrics ?? null, 103 + instrumental: Boolean(raw.instrumental), 104 + } 105 + } 106 + 107 + async function load_lyrics(next_key: string, params: Omit<Props, 'current_time_sec'>) { 108 + request_key = next_key 109 + error_message = '' 110 + loading = true 111 + 112 + if (Object.prototype.hasOwnProperty.call(cache_by_key, next_key)) { 113 + lyrics_data = cache_by_key[next_key] ?? null 114 + loading = false 115 + return 116 + } 117 + 118 + try { 119 + const get_query = encode_query({ 120 + track_name: params.track_name?.trim() || '', 121 + artist_name: params.artist_name?.trim() || '', 122 + album_name: params.album_name?.trim() || undefined, 123 + duration: 124 + typeof params.duration_sec === 'number' && 125 + Number.isFinite(params.duration_sec) && 126 + params.duration_sec > 0 127 + ? Math.round(params.duration_sec) 128 + : undefined, 129 + }) 130 + const exact_raw = (await get_json( 131 + `https://lrclib.net/api/get?${get_query}`, 132 + )) as LrclibApiTrack | null 133 + if (request_key !== next_key) return 134 + const exact = exact_raw ? normalize_track(exact_raw) : null 135 + 136 + if (exact) { 137 + cache_by_key[next_key] = exact 138 + lyrics_data = exact 139 + return 140 + } 141 + 142 + const search_query = encode_query({ 143 + track_name: params.track_name?.trim() || '', 144 + artist_name: params.artist_name?.trim() || '', 145 + }) 146 + const found_raw = (await get_json(`https://lrclib.net/api/search?${search_query}`)) as 147 + | LrclibApiTrack[] 148 + | null 149 + 150 + if (request_key !== next_key) return 151 + const found = found_raw?.map(normalize_track) ?? [] 152 + const best = found.find((item) => item.synced_lyrics || item.plain_lyrics) ?? null 153 + cache_by_key[next_key] = best 154 + lyrics_data = best 155 + } catch (err) { 156 + if (request_key !== next_key) return 157 + error_message = err instanceof Error ? err.message : 'Failed to load lyrics' 158 + lyrics_data = null 159 + } finally { 160 + if (request_key === next_key) loading = false 161 + } 162 + } 163 + 164 + function parse_synced_lyrics(input: string): LrcLine[] { 165 + if (!input) return [] 166 + const lines: LrcLine[] = [] 167 + for (const row of input.split('\n')) { 168 + const text = row.replace(/\[[^\]]+\]/g, '').trim() 169 + const stamps = [...row.matchAll(/\[(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?\]/g)] 170 + for (const stamp of stamps) { 171 + const min = Number(stamp[1] || 0) 172 + const sec = Number(stamp[2] || 0) 173 + const frac_raw = stamp[3] || '' 174 + const frac_ms = frac_raw ? Number(frac_raw.padEnd(3, '0').slice(0, 3)) : 0 175 + const total_sec = min * 60 + sec + frac_ms / 1000 176 + if (Number.isFinite(total_sec)) { 177 + lines.push({ time_sec: total_sec, text }) 178 + } 179 + } 180 + } 181 + return lines.sort((a, b) => a.time_sec - b.time_sec) 182 + } 183 + 184 + function find_active_line_index(lines: LrcLine[], time_sec: number) { 185 + if (!lines.length || !Number.isFinite(time_sec)) return -1 186 + let lo = 0 187 + let hi = lines.length - 1 188 + let answer = -1 189 + while (lo <= hi) { 190 + const mid = (lo + hi) >> 1 191 + if (lines[mid].time_sec <= time_sec) { 192 + answer = mid 193 + lo = mid + 1 194 + } else { 195 + hi = mid - 1 196 + } 197 + } 198 + return answer 199 + } 200 + </script> 201 + 202 + <aside 203 + class="pointer-events-none absolute right-0 box-border flex h-full overflow-hidden" 204 + transition:fly={{ x: '100%', duration: 150, opacity: 1 }} 205 + > 206 + <div class="h-full w-5 shadow-[inset_-20px_0_20px_-20px_#000000]"></div> 207 + <div 208 + class="pointer-events-auto relative -mt-px flex w-[var(--right-sidebar-width)] flex-col border-l border-[var(--border-color)] bg-black" 209 + > 210 + <div class="sticky top-0 z-10 border-b border-white/10 bg-black/75 px-4 py-3 backdrop-blur-md"> 211 + <h3 class="m-0 text-base font-semibold">Lyrics</h3> 212 + {#if artist_name && track_name} 213 + <div class="mt-1 truncate text-xs text-white/70">{artist_name} - {track_name}</div> 214 + {/if} 215 + </div> 216 + 217 + <div class="flex-1 overflow-y-auto px-4 py-3"> 218 + {#if !query_key} 219 + <p class="m-0 text-sm text-white/70">Play a track to load lyrics.</p> 220 + {:else if loading} 221 + <p class="m-0 text-sm text-white/70">Loading lyrics...</p> 222 + {:else if error_message} 223 + <p class="m-0 text-sm text-white/70">{error_message}</p> 224 + {:else if lyrics_data?.instrumental} 225 + <p class="m-0 text-sm text-white/70">Instrumental track.</p> 226 + {:else if synced_lines.length > 0} 227 + <div class="space-y-1"> 228 + {#each synced_lines as line, i} 229 + <p 230 + class={`m-0 py-1 text-[15px] leading-6 transition-colors duration-100 ${ 231 + i === active_line_index ? 'font-medium text-white' : 'text-white/60' 232 + }`} 233 + > 234 + {line.text || '♪'} 235 + </p> 236 + {/each} 237 + </div> 238 + {:else if plain_lines.length > 0} 239 + <div class="space-y-1"> 240 + {#each plain_lines as line} 241 + <p class="m-0 py-1 text-[15px] leading-6 text-white/80">{line}</p> 242 + {/each} 243 + </div> 244 + {:else} 245 + <p class="m-0 text-sm text-white/70">No lyrics found.</p> 246 + {/if} 247 + </div> 248 + </div> 249 + </aside>
+54 -2
src/components/Player.svelte
··· 12 12 time_record, 13 13 } from '../lib/player' 14 14 import { get_duration } from '../lib/helpers' 15 - import { queue_visible, toggle_queue_visibility, queue, shuffle, repeat } from '../lib/queue' 15 + import { queue_visible, queue, shuffle, repeat } from '../lib/queue' 16 + import { lyrics_state, toggle_lyrics_visibility } from '$lib/lyrics.svelte' 16 17 import { get_track, is_dev } from '$lib/data' 17 18 import { dragged } from '$lib/drag-drop' 18 19 import * as dragGhost from './DragGhost.svelte' ··· 24 25 } from '$lib/menus' 25 26 import { ipc_renderer } from '$lib/window' 26 27 import { tracks_page_item_ids } from './TrackList.svelte' 28 + import { onDestroy } from 'svelte' 27 29 28 30 export let on_toggle_visualizer: () => void 31 + 32 + function toggle_queue() { 33 + $queue_visible = !$queue_visible 34 + lyrics_state.visible = false 35 + } 36 + $: ipc_renderer.invoke('update:Show Queue', $queue_visible) 37 + ipc_renderer.on('Show Queue', toggle_queue) 38 + onDestroy(() => { 39 + ipc_renderer.removeListener('Show Queue', toggle_queue) 40 + }) 41 + 42 + function toggle_lyrics() { 43 + toggle_lyrics_visibility() 44 + } 45 + ipc_renderer.on('Show Lyrics', toggle_lyrics) 46 + onDestroy(() => { 47 + ipc_renderer.removeListener('Show Lyrics', toggle_lyrics) 48 + }) 29 49 30 50 async function playing_context_menu() { 31 51 const playing = queue.getCurrent() ··· 308 328 <Slider class="mr-2 w-[110px]" bind:value={$volume} max={1} /> 309 329 <button 310 330 type="button" 331 + class="mr-2" 332 + aria-label="Toggle lyrics" 333 + tabindex="-1" 334 + on:mousedown|preventDefault 335 + on:click={toggle_lyrics} 336 + class:on={lyrics_state.visible} 337 + > 338 + <svg 339 + xmlns="http://www.w3.org/2000/svg" 340 + class="parent-active-zoom lyrics-icon" 341 + height="24px" 342 + viewBox="0 0 24 24" 343 + width="24px" 344 + style="fill:none" 345 + stroke="currentColor" 346 + stroke-width="2" 347 + stroke-linecap="round" 348 + stroke-linejoin="round" 349 + > 350 + <path stroke="none" d="M0 0h24v24H0z" fill="none" /> 351 + <path d="M15 12.9a5 5 0 1 0 -3.902 -3.9" /> 352 + <path d="M15 12.9l-3.902 -3.899l-7.513 8.584a2 2 0 1 0 2.827 2.83l8.588 -7.515" /> 353 + </svg> 354 + </button> 355 + <button 356 + type="button" 311 357 aria-label="Toggle queue" 312 358 tabindex="-1" 313 359 on:mousedown|preventDefault 314 - on:click={toggle_queue_visibility} 360 + on:click={toggle_queue} 315 361 class:on={$queue_visible} 316 362 > 317 363 <svg ··· 412 458 font-family: 'Open Sans' // for monospace digits 413 459 .on svg 414 460 fill: var(--icon-highlight-color) 461 + .on .lyrics-icon 462 + stroke: var(--icon-highlight-color) 415 463 button.volume-icon 416 464 width: 24px 417 465 padding-right: 4px ··· 420 468 translate: 4px 421 469 .low 422 470 translate: 2px 471 + .lyrics-icon 472 + width: 18px 473 + height: 18px 474 + stroke-width: 2.4 423 475 </style>
+14
src/electron/menubar.ts
··· 178 178 }, 179 179 }, 180 180 { 181 + label: 'Show Lyrics', 182 + id: 'Show Lyrics', 183 + type: 'checkbox', 184 + accelerator: 'CmdOrCtrl+L', 185 + click() { 186 + web_contents.send('Show Lyrics') 187 + }, 188 + }, 189 + { 181 190 label: 'Toggle Visualizer', 182 191 type: 'checkbox', 183 192 accelerator: 'CmdOrCtrl+T', ··· 348 357 ipc_main.handle('update:Show Queue', (_, checked) => { 349 358 const item = menu.getMenuItemById('Show Queue') 350 359 if (!item) return handle_missing('Show Queue') 360 + item.checked = checked 361 + }) 362 + ipc_main.handle('update:Show Lyrics', (_, checked) => { 363 + const item = menu.getMenuItemById('Show Lyrics') 364 + if (!item) return handle_missing('Show Lyrics') 351 365 item.checked = checked 352 366 }) 353 367 }
+2
src/electron/typed_ipc.ts
··· 122 122 volumeUp: () => void 123 123 volumeDown: () => void 124 124 'Show Queue': () => void 125 + 'Show Lyrics': () => void 125 126 'Toggle Visualizer': () => void 126 127 ToggleQuickNav: () => void 127 128 'Group Album Tracks': (checked: boolean) => void ··· 190 191 'update:Shuffle': (checked: boolean) => void 191 192 'update:Repeat': (checked: boolean) => void 192 193 'update:Show Queue': (checked: boolean) => void 194 + 'update:Show Lyrics': (checked: boolean) => void 193 195 } 194 196 195 197 export type IpcMain = TypedIpcMain<Events, Commands>
+22
src/lib/lyrics.svelte.ts
··· 1 + import { queue_visible } from './queue' 2 + import { ipc_renderer } from './window' 3 + 4 + class LyricsState { 5 + visible = $state(false) 6 + 7 + toggle() { 8 + this.visible = !this.visible 9 + } 10 + } 11 + 12 + export const lyrics_state = new LyricsState() 13 + $effect.root(() => { 14 + ipc_renderer.invoke('update:Show Lyrics', lyrics_state.visible) 15 + }) 16 + 17 + export function toggle_lyrics_visibility() { 18 + if (!lyrics_state.visible) { 19 + queue_visible.set(false) 20 + } 21 + lyrics_state.toggle() 22 + }