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

Autoscroll lyrics

Kasper (Mar 8, 2026, 1:49 PM +0100) 4386be94 8404e1c7

+161 -67
+2 -2
src/App.svelte
··· 282 282 track_name={$playing_track?.name || ''} 283 283 artist_name={$playing_track?.artist || ''} 284 284 album_name={$playing_track?.albumName || ''} 285 - duration_sec={$time_record.duration || null} 286 - current_time_sec={$time_record.elapsed || 0} 285 + duration_sec={$time_record.duration} 286 + current_time_sec={$time_record.elapsed} 287 287 /> 288 288 {/if} 289 289 </div>
+15 -14
src/components/Button.svelte
··· 1 1 <script lang="ts"> 2 - import type { HTMLBaseAttributes } from 'svelte/elements' 2 + import type { HTMLButtonAttributes } from 'svelte/elements' 3 3 4 - // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 - interface $$Props extends HTMLBaseAttributes { 4 + interface Props extends HTMLButtonAttributes { 6 5 secondary?: boolean 7 6 danger?: boolean 8 7 thin?: boolean 9 - type?: 'button' | 'submit' | 'reset' 10 8 } 11 9 12 - export let secondary = false 13 - export let danger = false 14 - export let thin = false 15 - export let type: 'button' | 'submit' | 'reset' = 'button' 16 - let normal = !danger && !secondary 17 - $: normal = !danger && !secondary 10 + let { 11 + secondary = false, 12 + danger = false, 13 + thin = false, 14 + type = 'button', 15 + children, 16 + ...rest_props 17 + }: Props = $props() 18 + 19 + const normal = $derived(!danger && !secondary) 18 20 </script> 19 21 20 22 <button 21 - on:click 22 - on:mousedown 23 23 class:normal 24 24 class:secondary 25 25 class:danger 26 26 class:thin 27 27 {type} 28 - {...$$restProps} 28 + {...rest_props} 29 + style:-webkit-app-region="no-drag" 29 30 > 30 - <slot /> 31 + {@render children?.()} 31 32 </button> 32 33 33 34 <style lang="sass">
+102 -18
src/components/Lyrics.svelte
··· 1 1 <script lang="ts"> 2 2 import { fly } from 'svelte/transition' 3 + import { ScrollAnimation } from '$lib/scroll' 4 + import { seek } from '$lib/player' 5 + import Button from './Button.svelte' 3 6 4 7 type LrcLine = { 5 8 time_sec: number ··· 19 22 } 20 23 21 24 type Props = { 22 - track_name?: string 23 - artist_name?: string 24 - album_name?: string 25 - duration_sec?: number | null 26 - current_time_sec?: number 25 + track_name: string 26 + artist_name: string 27 + album_name: string 28 + duration_sec: number | null 29 + current_time_sec: number 27 30 } 28 31 29 32 const cache_by_key: Record<string, LrclibTrack | null> = {} 30 33 31 - let { 32 - track_name = '', 33 - artist_name = '', 34 - album_name = '', 35 - duration_sec = null, 36 - current_time_sec = 0, 37 - }: Props = $props() 34 + let { track_name, artist_name, album_name, duration_sec, current_time_sec }: Props = $props() 38 35 39 36 let loading = $state(false) 40 37 let error_message = $state('') 41 38 let lyrics_data = $state<LrclibTrack | null>(null) 42 39 let request_key = $state('') 40 + 41 + let follow_paused = $state(false) 42 + let paused_temporarily = $state(false) 43 + let initial_scroll_done = $state(false) 44 + 45 + let lines_element: HTMLElement | null = null 46 + let line_elements = $state<Array<HTMLParagraphElement | null>>([]) 47 + const follow_scroll_animation = new ScrollAnimation() 48 + const follow_scroll_duration_ms = 220 43 49 44 50 const query_key = $derived(make_key(track_name, artist_name, album_name, duration_sec)) 45 51 const synced_lines = $derived(parse_synced_lyrics(lyrics_data?.synced_lyrics ?? '')) ··· 52 58 ) 53 59 54 60 $effect(() => { 61 + query_key 62 + follow_paused = false 63 + line_elements = [] 64 + initial_scroll_done = false 65 + }) 66 + 67 + $effect(() => { 68 + if (follow_paused || paused_temporarily) return 69 + if (!lines_element || active_line_index < 0 || synced_lines.length === 0) return 70 + const active_el = line_elements[active_line_index] 71 + if (!active_el) return 72 + const target_top = 73 + active_el.offsetTop - lines_element.clientHeight / 2 + active_el.clientHeight / 2 74 + if (!initial_scroll_done) { 75 + follow_scroll_animation.cancel() 76 + lines_element.scrollTop = Math.max( 77 + 0, 78 + Math.min(target_top, lines_element.scrollHeight - lines_element.clientHeight), 79 + ) 80 + initial_scroll_done = true 81 + return 82 + } 83 + follow_scroll_animation.smooth_scroll_to(lines_element, target_top, follow_scroll_duration_ms) 84 + }) 85 + 86 + $effect(() => { 55 87 const key = query_key 56 88 if (!key) { 57 89 lyrics_data = null ··· 80 112 return `${a}|${t}|${alb}|${d}` 81 113 } 82 114 115 + function seek_to_lyric_time(time_sec: number) { 116 + const selected_text = window.getSelection()?.toString().trim() 117 + if (selected_text) return 118 + seek(time_sec) 119 + } 120 + 83 121 function encode_query(params: Record<string, string | number | undefined>) { 84 122 const parts: string[] = [] 85 123 for (const [key, value] of Object.entries(params)) { ··· 197 235 } 198 236 return answer 199 237 } 238 + 239 + function pause_follow() { 240 + follow_scroll_animation.cancel() 241 + follow_paused = true 242 + } 243 + 244 + function start_temporary_pause() { 245 + follow_scroll_animation.cancel() 246 + paused_temporarily = true 247 + } 248 + 249 + function end_temporary_pause() { 250 + paused_temporarily = false 251 + } 200 252 </script> 201 253 202 254 <aside ··· 207 259 <div 208 260 class="pointer-events-auto relative -mt-px flex w-[var(--right-sidebar-width)] flex-col border-l border-[var(--border-color)] bg-black" 209 261 > 210 - <div class="sticky top-0 z-10 border-b border-white/10 bg-black/75 px-4 py-3 backdrop-blur-md"> 262 + <div 263 + class="sticky top-0 z-10 flex justify-between border-b border-white/10 bg-black/75 px-4 py-3 backdrop-blur-md" 264 + > 211 265 <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> 266 + {#if follow_paused && synced_lines.length > 0} 267 + <Button 268 + class="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/15" 269 + secondary 270 + thin 271 + onclick={() => (follow_paused = false)} 272 + > 273 + Resume follow 274 + </Button> 214 275 {/if} 215 276 </div> 216 277 217 - <div class="flex-1 overflow-y-auto px-4 py-3"> 278 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 279 + <div 280 + class="flex-1 overflow-y-auto px-4 py-3 select-text" 281 + role="region" 282 + bind:this={lines_element} 283 + onwheel={pause_follow} 284 + onscroll={() => { 285 + if (paused_temporarily) pause_follow() 286 + }} 287 + onmousedown={start_temporary_pause} 288 + ontouchstart={start_temporary_pause} 289 + ontouchend={end_temporary_pause} 290 + ontouchcancel={end_temporary_pause} 291 + onmouseup={end_temporary_pause} 292 + onmouseleave={end_temporary_pause} 293 + > 218 294 {#if !query_key} 219 295 <p class="m-0 text-sm text-white/70">Play a track to load lyrics.</p> 220 296 {:else if loading} ··· 227 303 <div class="space-y-1"> 228 304 {#each synced_lines as line, i} 229 305 <p 230 - class={`m-0 py-1 text-[15px] leading-6 transition-colors duration-100 ${ 306 + bind:this={line_elements[i]} 307 + class={`m-0 min-h-lh cursor-text py-1 text-[15px] transition-colors duration-100 ${ 231 308 i === active_line_index ? 'font-medium text-white' : 'text-white/60' 232 309 }`} 233 310 > 234 - {line.text || '♪'} 311 + <span 312 + role="none" 313 + class="cursor-pointer py-1 leading-6" 314 + class:hover:text-gray-300={i !== active_line_index} 315 + onclick={() => seek_to_lyric_time(line.time_sec)} 316 + > 317 + {line.text || ''} 318 + </span> 235 319 </p> 236 320 {/each} 237 321 </div>
+4 -33
src/components/Queue.svelte
··· 15 15 import { ipc_listen, ipc_renderer } from '$lib/window' 16 16 import { check_shortcut } from '$lib/helpers' 17 17 import { fly } from 'svelte/transition' 18 - import { cubicOut } from 'svelte/easing' 18 + import { ScrollAnimation } from '$lib/scroll' 19 19 import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 20 20 import type { SelectedTracksAction } from '$electron/typed_ipc' 21 21 import { ··· 109 109 onDestroy(ipc_listen('context.Remove from Queue', remove_from_queue)) 110 110 111 111 let queue_element: HTMLElement 112 - let indicator_scroll_anim: number | null = null 113 - const indicator_scroll_duration_ms = 200 114 - function smooth_scroll_to(top: number) { 115 - const max_top = Math.max(0, queue_element.scrollHeight - queue_element.clientHeight) 116 - const target_top = Math.max(0, Math.min(top, max_top)) 117 - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 118 - queue_element.scrollTop = target_top 119 - return 120 - } 121 - if (indicator_scroll_anim) { 122 - cancelAnimationFrame(indicator_scroll_anim) 123 - } 124 - const start_top = queue_element.scrollTop 125 - const start_ts = performance.now() 126 - const frame = (now: number) => { 127 - const p = Math.min(1, (now - start_ts) / indicator_scroll_duration_ms) 128 - queue_element.scrollTop = start_top + (target_top - start_top) * cubicOut(p) 129 - if (p < 1) { 130 - indicator_scroll_anim = requestAnimationFrame(frame) 131 - } else { 132 - indicator_scroll_anim = null 133 - } 134 - } 135 - indicator_scroll_anim = requestAnimationFrame(frame) 136 - } 137 - onDestroy(() => { 138 - if (indicator_scroll_anim !== null) { 139 - cancelAnimationFrame(indicator_scroll_anim) 140 - indicator_scroll_anim = null 141 - } 142 - }) 112 + const indicator_scroll_anim = new ScrollAnimation() 143 113 144 114 function handle_action(action: SelectedTracksAction) { 145 115 const first_index = selection.find_first_index() ··· 256 226 if (show) { 257 227 const old_top = queue_element.scrollTop + history_rows * entry_height 258 228 queue_element.scrollTop = old_top 259 - smooth_scroll_to(old_top - entry_height * 5) 229 + const top = old_top - entry_height * 5 230 + indicator_scroll_anim.smooth_scroll_to(queue_element, top, 200) 260 231 } else { 261 232 queue_element.scrollTop = 0 262 233 }
+38
src/lib/scroll.ts
··· 1 + import { cubicOut } from 'svelte/easing' 2 + 3 + export class ScrollAnimation { 4 + frame_id: number | null = null 5 + 6 + cancel() { 7 + if (this.frame_id !== null) { 8 + cancelAnimationFrame(this.frame_id) 9 + this.frame_id = null 10 + } 11 + } 12 + 13 + smooth_scroll_to(element: HTMLElement, top: number, duration_ms = 200) { 14 + this.cancel() 15 + if (!element.isConnected) { 16 + return 17 + } 18 + const max_top = Math.max(0, element.scrollHeight - element.clientHeight) 19 + const target_top = Math.max(0, Math.min(top, max_top)) 20 + const reduced_motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches 21 + if (reduced_motion) { 22 + element.scrollTop = target_top 23 + return 24 + } 25 + const start_top = element.scrollTop 26 + const start_ts = performance.now() 27 + const frame = (now: number) => { 28 + const p = Math.min(1, (now - start_ts) / duration_ms) 29 + element.scrollTop = start_top + (target_top - start_top) * cubicOut(p) 30 + if (p < 1) { 31 + this.frame_id = requestAnimationFrame(frame) 32 + } else { 33 + this.frame_id = null 34 + } 35 + } 36 + this.frame_id = requestAnimationFrame(frame) 37 + } 38 + }