From 059900e85e7011fdeed61737584868927f409316 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:42:36 +0400 Subject: [PATCH 1/2] refactor(lyrics): replace spring scroller with cubic ease-out animator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spring model (velocity × damping per rAF frame) produced an abrupt-looking lurch on each line change — especially noticeable at the stiffness/damping values used (0.1 / 0.78). Replace SpringScroller with EaseScroller: a fixed-duration (650 ms) cubic ease-out animation that starts from the current scroll position and decelerates smoothly to the target. Restarting mid-flight is clean — the next call captures the container's current scrollTop as the new start point, so fast line changes never skip or jerk. Applied to both the fullscreen Apple-style lyrics and the sidebar LyricsPane. User-scroll cancellation (stop() + 4 s resume) is preserved unchanged. --- src/components/FullscreenPlayer.tsx | 28 ++++----- src/components/LyricsPane.tsx | 21 +++---- src/utils/springScroll.ts | 89 ++++++++++------------------- 3 files changed, 50 insertions(+), 88 deletions(-) diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index bc12cc86..503b76d7 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -13,7 +13,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics'; import { useAuthStore } from '../store/authStore'; import type { LrcLine } from '../api/lrclib'; import type { Track } from '../store/playerStore'; -import { SpringScroller, targetForFraction } from '../utils/springScroll'; +import { EaseScroller, targetForFraction } from '../utils/springScroll'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -47,23 +47,18 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra const [activeIdx, setActiveIdx] = useState(-1); const activeIdxRef = useRef(-1); - const containerRef = useRef(null); - const springRef = useRef(null); + const containerRef = useRef(null); + const scrollerRef = useRef(null); const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const wordRefs = useRef([]); const prevWord = useRef({ line: -1, word: -1 }); const isUserScroll = useRef(false); const scrollTimer = useRef | null>(null); - // Create/destroy the SpringScroller when the container mounts. const setContainerRef = useCallback((el: HTMLDivElement | null) => { - (containerRef as React.MutableRefObject).current = el; - if (el) { - springRef.current = new SpringScroller(el, 0.1, 0.78); - } else { - springRef.current?.stop(); - springRef.current = null; - } + containerRef.current = el; + scrollerRef.current?.stop(); + scrollerRef.current = el ? new EaseScroller(el) : null; }, []); // Reset everything on track change. @@ -73,7 +68,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra prevWord.current = { line: -1, word: -1 }; activeIdxRef.current = -1; setActiveIdx(-1); - springRef.current?.jump(0); + scrollerRef.current?.jump(0); }, [currentTrack?.id]); // 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)); }, [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(() => { if (activeIdx < 0 || isUserScroll.current) return; const el = lineRefs.current[activeIdx]; const box = containerRef.current; - if (!el || !box || !springRef.current) return; - springRef.current.scrollTo(targetForFraction(box, el, 0.35)); + if (!el || !box || !scrollerRef.current) return; + scrollerRef.current.scrollTo(targetForFraction(box, el, 0.35)); }, [activeIdx]); // Word-sync: imperative DOM updates, zero React re-renders per tick. @@ -134,8 +129,7 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra }, [useWords, wordLines]); const handleUserScroll = useCallback(() => { - // Stop spring animation so it doesn't fight the user's scroll. - springRef.current?.stop(); + scrollerRef.current?.stop(); isUserScroll.current = true; if (scrollTimer.current) clearTimeout(scrollTimer.current); scrollTimer.current = setTimeout(() => { isUserScroll.current = false; }, 4000); diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx index 46982993..34dbdeef 100644 --- a/src/components/LyricsPane.tsx +++ b/src/components/LyricsPane.tsx @@ -6,7 +6,7 @@ import { useLyrics, type WordLyricsLine } from '../hooks/useLyrics'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import type { Track } from '../store/playerStore'; -import { SpringScroller, targetForFraction } from '../utils/springScroll'; +import { EaseScroller, targetForFraction } from '../utils/springScroll'; interface Props { currentTrack: Track | null; @@ -33,37 +33,32 @@ export default function LyricsPane({ currentTrack }: Props) { const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0); const containerRef = useRef(null); - const springRef = useRef(null); + const scrollerRef = useRef(null); const lineRefs = useRef<(HTMLDivElement | null)[]>([]); const wordRefs = useRef([]); const prevActive = useRef({ line: -1, word: -1 }); const isUserScroll = useRef(false); const scrollTimer = useRef | null>(null); - // Attach/detach SpringScroller when the pane mounts. const setContainerRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el; - springRef.current?.stop(); - springRef.current = el ? new SpringScroller(el, 0.1, 0.78) : null; + scrollerRef.current?.stop(); + scrollerRef.current = el ? new EaseScroller(el) : null; }, []); - // Pause auto-scroll when user manually scrolls; stop spring so it doesn't fight. const handleUserScroll = useCallback(() => { - springRef.current?.stop(); + scrollerRef.current?.stop(); isUserScroll.current = true; if (scrollTimer.current) clearTimeout(scrollTimer.current); 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) => { if (isUserScroll.current) return; const container = containerRef.current; if (!container) return; - if (sidebarLyricsStyle === 'apple' && springRef.current) { - springRef.current.scrollTo(targetForFraction(container, el, 0.35)); + if (sidebarLyricsStyle === 'apple' && scrollerRef.current) { + scrollerRef.current.scrollTo(targetForFraction(container, el, 0.35)); } else { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } @@ -74,7 +69,7 @@ export default function LyricsPane({ currentTrack }: Props) { lineRefs.current = []; wordRefs.current = []; prevActive.current = { line: -1, word: -1 }; - springRef.current?.jump(0); + scrollerRef.current?.jump(0); }, [currentTrack?.id]); // Imperative tracker — subscribes directly to the store, zero React re-renders per tick. diff --git a/src/utils/springScroll.ts b/src/utils/springScroll.ts index ce18f221..93a445b2 100644 --- a/src/utils/springScroll.ts +++ b/src/utils/springScroll.ts @@ -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: - * velocity += (target − position) × stiffness - * velocity *= damping - * position += velocity - * - * 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. + * Animates scrollTop from the current position to the target over a fixed + * duration using a cubic ease-out curve. Calling scrollTo() mid-flight + * restarts cleanly from wherever the container currently sits, so fast + * line changes never look jerky or skip. */ -export class SpringScroller { - private container : HTMLElement; - private target = 0; - private velocity = 0; +export class EaseScroller { + private container : HTMLElement; + private startY = 0; + private targetY = 0; + private startTime = 0; private rafId: number | null = null; - private readonly stiffness : number; - private readonly damping : number; - private readonly maxVelocity: number; + private readonly duration: number; - constructor( - container : HTMLElement, - stiffness = 0.065, // gentle pull - damping = 0.84, // smooth settle, no oscillation - 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; + constructor(container: HTMLElement, duration = 650) { + this.container = container; + this.targetY = container.scrollTop; + this.duration = duration; } - scrollTo(targetY: number) { - this.target = Math.max(0, targetY); - if (this.rafId === null) this.tick(); + scrollTo(y: number) { + this.startY = this.container.scrollTop; + this.targetY = Math.max(0, y); + this.startTime = performance.now(); + if (this.rafId === null) this.rafId = requestAnimationFrame(this.tick); } stop() { @@ -48,42 +33,30 @@ export class SpringScroller { cancelAnimationFrame(this.rafId); this.rafId = null; } - this.velocity = 0; } - /** Teleport without animation (e.g. on track reset). */ jump(y: number) { this.stop(); - this.target = y; this.container.scrollTop = y; + this.targetY = y; } - private tick = () => { - const pos = this.container.scrollTop; - const delta = this.target - pos; - - let v = (this.velocity + delta * this.stiffness) * this.damping; - // 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 { + private tick = (now: number) => { + const t = Math.min((now - this.startTime) / this.duration, 1); + const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out + this.container.scrollTop = this.startY + (this.targetY - this.startY) * ease; + if (t < 1) { this.rafId = requestAnimationFrame(this.tick); + } else { + this.container.scrollTop = this.targetY; + this.rafId = null; } }; } /** - * Convenience: compute the scroll position that places `el` at `fraction` - * from the top of `container` (0 = top, 0.5 = centre, 0.35 = Apple-style). + * Compute the scroll position that places `el` at `fraction` from the top + * of `container` (0 = top edge, 0.35 = Apple Music-style, 0.5 = centre). */ export function targetForFraction( container: HTMLElement, From 65413c954e95ef9ff6a2efef91121a4fab6f3e00 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:00:46 +0400 Subject: [PATCH 2/2] feat(fs-lyrics): fade bottom edge of plain lyrics scroll viewport Synced lyrics always fill the screen due to auto-scroll, so the existing fsa-fade-bottom overlay naturally fades content below the active line. Plain (unsynced) lyrics start from the top and may not fill the viewport, leaving no content for that overlay to fade. Add mask-image to .fsa-lyrics-container when rendering plain lyrics (--plain modifier class). The mask operates on the visible scroll viewport rather than the content, so the bottom fade is always present regardless of scroll position. --- src/components/FullscreenPlayer.tsx | 4 +++- src/styles/components.css | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 503b76d7..61e993a4 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -143,9 +143,11 @@ const FsLyricsApple = memo(function FsLyricsApple({ currentTrack }: { currentTra if (!currentTrack || loading) return null; + const isPlain = !hasSynced && !!plainLyrics; + return (