Merge pull request #214 from kilyabin/am-lyrics-improvements

refactor(fs-lyrics): smoother line scrolling + bottom fade for plain lyrics
This commit is contained in:
Frank Stellmacher
2026-04-19 19:20:02 +02:00
committed by GitHub
4 changed files with 59 additions and 89 deletions
+14 -18
View File
@@ -13,7 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import type { LrcLine } from '../api/lrclib'; import type { LrcLine } from '../api/lrclib';
import type { Track } from '../store/playerStore'; import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll'; import { EaseScroller, targetForFraction } from '../utils/springScroll';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -47,23 +47,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
const [activeIdx, setActiveIdx] = useState(-1); const [activeIdx, setActiveIdx] = useState(-1);
const activeIdxRef = useRef(-1); const activeIdxRef = useRef(-1);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const springRef = useRef<SpringScroller | null>(null); const scrollerRef = useRef<EaseScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]); const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevWord = useRef({ line: -1, word: -1 }); const prevWord = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false); const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Create/destroy the SpringScroller when the container mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => { const setContainerRef = useCallback((el: HTMLDivElement | null) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = el; containerRef.current = el;
if (el) { scrollerRef.current?.stop();
springRef.current = new SpringScroller(el, 0.1, 0.78); scrollerRef.current = el ? new EaseScroller(el) : null;
} else {
springRef.current?.stop();
springRef.current = null;
}
}, []); }, []);
// Reset everything on track change. // Reset everything on track change.
@@ -73,7 +68,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
prevWord.current = { line: -1, word: -1 }; prevWord.current = { line: -1, word: -1 };
activeIdxRef.current = -1; activeIdxRef.current = -1;
setActiveIdx(-1); setActiveIdx(-1);
springRef.current?.jump(0); scrollerRef.current?.jump(0);
}, [currentTrack?.id]); }, [currentTrack?.id]);
// Subscribe to playback time — only triggers React setState when line changes. // Subscribe to playback time — only triggers React setState when line changes.
@@ -92,13 +87,13 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
return usePlayerStore.subscribe(s => apply(s.currentTime)); return usePlayerStore.subscribe(s => apply(s.currentTime));
}, [hasSynced, currentTrack?.id]); }, [hasSynced, currentTrack?.id]);
// Spring-scroll active line to ~35% from the top of the container. // Ease-scroll active line to ~35% from the top of the container.
useEffect(() => { useEffect(() => {
if (activeIdx < 0 || isUserScroll.current) return; if (activeIdx < 0 || isUserScroll.current) return;
const el = lineRefs.current[activeIdx]; const el = lineRefs.current[activeIdx];
const box = containerRef.current; const box = containerRef.current;
if (!el || !box || !springRef.current) return; if (!el || !box || !scrollerRef.current) return;
springRef.current.scrollTo(targetForFraction(box, el, 0.35)); scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35));
}, [activeIdx]); }, [activeIdx]);
// Word-sync: imperative DOM updates, zero React re-renders per tick. // Word-sync: imperative DOM updates, zero React re-renders per tick.
@@ -134,8 +129,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
}, [useWords, wordLines]); }, [useWords, wordLines]);
const handleUserScroll = useCallback(() => { const handleUserScroll = useCallback(() => {
// Stop spring animation so it doesn't fight the user's scroll. scrollerRef.current?.stop();
springRef.current?.stop();
isUserScroll.current = true; isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current); if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000); scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
@@ -149,9 +143,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra
if (!currentTrack || loading) return null; if (!currentTrack || loading) return null;
const isPlain = !hasSynced && !!plainLyrics;
return ( return (
<div <div
className="fsa-lyrics-container" className={`fsa-lyrics-container${isPlain ? ' fsa-lyrics-container--plain' : ''}`}
ref={setContainerRef} ref={setContainerRef}
onWheel={handleUserScroll} onWheel={handleUserScroll}
onTouchMove={handleUserScroll} onTouchMove={handleUserScroll}
+8 -13
View File
@@ -6,7 +6,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore'; import type { Track } from '../store/playerStore';
import { SpringScroller, targetForFraction } from '../utils/springScroll'; import { EaseScroller, targetForFraction } from '../utils/springScroll';
interface Props { interface Props {
currentTrack: Track | null; currentTrack: Track | null;
@@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) {
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const springRef = useRef<SpringScroller | null>(null); const scrollerRef = useRef<EaseScroller | null>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const wordRefs = useRef<HTMLSpanElement[][]>([]); const wordRefs = useRef<HTMLSpanElement[][]>([]);
const prevActive = useRef({ line: -1, word: -1 }); const prevActive = useRef({ line: -1, word: -1 });
const isUserScroll = useRef(false); const isUserScroll = useRef(false);
const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const scrollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Attach/detach SpringScroller when the pane mounts.
const setContainerRef = useCallback((el: HTMLDivElement | null) => { const setContainerRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el; containerRef.current = el;
springRef.current?.stop(); scrollerRef.current?.stop();
springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null; scrollerRef.current = el ? new EaseScroller(el) : null;
}, []); }, []);
// Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight.
const handleUserScroll = useCallback(() => { const handleUserScroll = useCallback(() => {
springRef.current?.stop(); scrollerRef.current?.stop();
isUserScroll.current = true; isUserScroll.current = true;
if (scrollTimer.current) clearTimeout(scrollTimer.current); if (scrollTimer.current) clearTimeout(scrollTimer.current);
scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000); scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000);
}, []); }, []);
// Scroll active line into view.
// Apple style: spring-animate to ~35% from top.
// Classic style: native scrollIntoView center.
const scrollToLine = useCallback((el: HTMLDivElement) => { const scrollToLine = useCallback((el: HTMLDivElement) => {
if (isUserScroll.current) return; if (isUserScroll.current) return;
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
if (sidebarLyricsStyle === 'apple' && springRef.current) { if (sidebarLyricsStyle === 'apple' && scrollerRef.current) {
springRef.current.scrollTo(targetForFraction(container, el, 0.35)); scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35));
} else { } else {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
@@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) {
lineRefs.current = []; lineRefs.current = [];
wordRefs.current = []; wordRefs.current = [];
prevActive.current = { line: -1, word: -1 }; prevActive.current = { line: -1, word: -1 };
springRef.current?.jump(0); scrollerRef.current?.jump(0);
}, [currentTrack?.id]); }, [currentTrack?.id]);
// Imperative tracker — subscribes directly to the store, zero React re-renders per tick. // Imperative tracker — subscribes directly to the store, zero React re-renders per tick.
+6
View File
@@ -3621,6 +3621,12 @@
pointer-events: none; pointer-events: none;
} }
/* Fade the bottom of the scroll viewport when showing plain lyrics */
.fsa-lyrics-container--plain {
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
.fsa-plain-line { .fsa-plain-line {
font-size: clamp(1.1rem, 2.5vh, 2.2rem); font-size: clamp(1.1rem, 2.5vh, 2.2rem);
font-weight: 500; font-weight: 500;
+31 -58
View File
@@ -1,46 +1,31 @@
/** /**
* Spring-based scroll animation iOS / Apple Music feel. * Duration-based ease-out scroll animator.
* *
* Uses a critically-damped spring model driven by rAF: * Animates scrollTop from the current position to the target over a fixed
* velocity += (target position) × stiffness * duration using a cubic ease-out curve. Calling scrollTo() mid-flight
* velocity *= damping * restarts cleanly from wherever the container currently sits, so fast
* position += velocity * line changes never look jerky or skip.
*
* Tuning:
* stiffness 0.04 0.10 lower = slower / more fluid
* damping 0.80 0.88 lower = more bounce; higher = overdamped / snappy
* maxVelocity caps initial lurch when target is far away
*
* A single SpringScroller instance per container avoids fighting rAF loops
* when the target changes before the previous animation finishes calling
* scrollTo() mid-flight just updates the target and the running loop picks it up.
*/ */
export class SpringScroller { export class EaseScroller {
private container : HTMLElement; private container : HTMLElement;
private target = 0; private startY = 0;
private velocity = 0; private targetY = 0;
private startTime = 0;
private rafId: number | null = null; private rafId: number | null = null;
private readonly stiffness : number; private readonly duration: number;
private readonly damping : number;
private readonly maxVelocity: number;
constructor( constructor(container: HTMLElement, duration = 650) {
container : HTMLElement, this.container = container;
stiffness = 0.065, // gentle pull this.targetY = container.scrollTop;
damping = 0.84, // smooth settle, no oscillation this.duration = duration;
maxVelocity = 28, // px/frame cap — prevents jarring lurch on large jumps
) {
this.container = container;
this.target = container.scrollTop;
this.stiffness = stiffness;
this.damping = damping;
this.maxVelocity = maxVelocity;
} }
scrollTo(targetY: number) { scrollTo(y: number) {
this.target = Math.max(0, targetY); this.startY = this.container.scrollTop;
if (this.rafId === null) this.tick(); this.targetY = Math.max(0, y);
this.startTime = performance.now();
if (this.rafId === null) this.rafId = requestAnimationFrame(this.tick);
} }
stop() { stop() {
@@ -48,42 +33,30 @@ export class SpringScroller {
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = null; this.rafId = null;
} }
this.velocity = 0;
} }
/** Teleport without animation (e.g. on track reset). */
jump(y: number) { jump(y: number) {
this.stop(); this.stop();
this.target = y;
this.container.scrollTop = y; this.container.scrollTop = y;
this.targetY = y;
} }
private tick = () => { private tick = (now: number) => {
const pos = this.container.scrollTop; const t = Math.min((now - this.startTime) / this.duration, 1);
const delta = this.target - pos; const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
this.container.scrollTop = this.startY + (this.targetY - this.startY) * ease;
let v = (this.velocity + delta * this.stiffness) * this.damping; if (t < 1) {
// Cap velocity so large distances don't start with a hard jerk.
if (v > this.maxVelocity) v = this.maxVelocity;
if (v < -this.maxVelocity) v = -this.maxVelocity;
this.velocity = v;
this.container.scrollTop += v;
const settled = Math.abs(v) < 0.12 && Math.abs(delta) < 0.5;
if (settled) {
this.container.scrollTop = this.target;
this.rafId = null;
this.velocity = 0;
} else {
this.rafId = requestAnimationFrame(this.tick); this.rafId = requestAnimationFrame(this.tick);
} else {
this.container.scrollTop = this.targetY;
this.rafId = null;
} }
}; };
} }
/** /**
* Convenience: compute the scroll position that places `el` at `fraction` * Compute the scroll position that places `el` at `fraction` from the top
* from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style). * of `container` (0 = top edge, 0.35 = Apple Music-style, 0.5 = centre).
*/ */
export function targetForFraction( export function targetForFraction(
container: HTMLElement, container: HTMLElement,