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] 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,