From 9b3a47a27f15c60b17a49102c690abcfb2fbdfad Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 10 Jul 2026 18:24:17 -0700 Subject: [PATCH] fix scroll follow pause on manual scroll --- .../lyrics/hooks/lyrics-animation-engine.ts | 23 +++---- .../hooks/use-synchronized-lyrics-base.ts | 69 ++++++++++++++----- .../lyrics/synchronized-karaoke-lyrics.tsx | 8 ++- .../features/lyrics/synchronized-lyrics.tsx | 8 ++- 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts index 8032d37dd..384865cea 100644 --- a/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts +++ b/src/renderer/features/lyrics/hooks/lyrics-animation-engine.ts @@ -157,9 +157,7 @@ const collectLineParts = (lineElement: HTMLElement): PartData[] => { }; const collectOverlayParts = (lineElement: HTMLElement): PartData[] => { - const words = lineElement.querySelectorAll( - '.karaoke-overlay-word[data-duration]', - ); + const words = lineElement.querySelectorAll('.karaoke-overlay-word[data-duration]'); return Array.from(words).map((element) => ({ animationStartTimeMs: Number.POSITIVE_INFINITY, @@ -263,11 +261,7 @@ const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: nu part.isAnimating = true; }; -const animateWordParts = ( - parts: PartData[], - interpolatedTimeSec: number, - now: number, -): void => { +const animateWordParts = (parts: PartData[], interpolatedTimeSec: number, now: number): void => { for (const part of parts) { const partEnd = part.time + part.duration; @@ -703,8 +697,7 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): const ss = state.scroll; const scrollPausedByUser = ss.scrollResumeTime >= now; - const canAutoscroll = - follow && (!scrollPausedByUser || ss.pendingScroll || ss.scrollPos === -1); + const canAutoscroll = follow && !scrollPausedByUser; if (canAutoscroll) { if (activeElems.length === 0 && lines.length > 0) { @@ -760,14 +753,20 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): if (ss.wasUserScrolling && ss.scrollResumeTime < now) { ss.wasUserScrolling = false; + ss.pendingScroll = true; } return activeLineIndex; }; export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => { - state.scroll.wasUserScrolling = true; - state.scroll.scrollResumeTime = Date.now() + pauseDurationMs; + const ss = state.scroll; + cancelActiveLyricsScroll(ss); + ss.programmaticScrollUntil = 0; + ss.skipScrolls = 0; + ss.skipScrollsDecayTimes = []; + ss.wasUserScrolling = true; + ss.scrollResumeTime = Date.now() + pauseDurationMs; }; export const resumeLyricsAutoscroll = (state: AnimEngineState): void => { diff --git a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts index e29c6a13d..359745eaf 100644 --- a/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts +++ b/src/renderer/features/lyrics/hooks/use-synchronized-lyrics-base.ts @@ -1,5 +1,5 @@ import isElectron from 'is-electron'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { animateLyricsScrollTo, @@ -22,6 +22,8 @@ const utils = isElectron() ? window.api.utils : null; const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; export const LYRICS_SCROLL_CONTAINER_ID = 'sychronized-lyrics-scroll-container'; +export const MANUAL_SCROLL_PAUSE_MS = 2000; +const MANUAL_SCROLL_DRIFT_PX = 3; export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: number) => { const playbackType = usePlaybackType(); @@ -129,6 +131,19 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu resumeLyricsAutoscroll(scrollAnimStateRef.current); }, []); + const pauseManualScrollFollow = useCallback(() => { + userScrollingRef.current = true; + handleLyricsUserScroll(scrollAnimStateRef.current, MANUAL_SCROLL_PAUSE_MS); + + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + userScrollingRef.current = false; + }, MANUAL_SCROLL_PAUSE_MS); + }, []); + const containerStyle = useMemo( () => ({ @@ -170,13 +185,37 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu delayMsRef.current = newOffset; }, [offsetMs]); - useEffect(() => { - const container = - containerRef.current || - (document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement); - if (!container) return; + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const handleWheel = (event: WheelEvent) => { + if (event.deltaX === 0 && event.deltaY === 0) { + return; + } + + pauseManualScrollFollow(); + }; + + const handleTouchStart = () => { + pauseManualScrollFollow(); + }; const handleScroll = () => { + const scrollState = scrollAnimStateRef.current.scroll; + const isProgrammatic = Date.now() < scrollState.programmaticScrollUntil; + + if ( + !isProgrammatic && + scrollState.scrollPos >= 0 && + Math.abs(container.scrollTop - scrollState.scrollPos) > MANUAL_SCROLL_DRIFT_PX + ) { + pauseManualScrollFollow(); + return; + } + if (shouldSkipLyricsScrollEvent(scrollAnimStateRef.current)) { return; } @@ -193,22 +232,18 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu return; } - userScrollingRef.current = true; - handleLyricsUserScroll(scrollAnimStateRef.current); - - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - scrollTimeoutRef.current = setTimeout(() => { - userScrollingRef.current = false; - }, 3000); + pauseManualScrollFollow(); }; + container.addEventListener('wheel', handleWheel, { passive: true }); + container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('scroll', handleScroll, { passive: true }); return () => { + container.removeEventListener('wheel', handleWheel); + container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } @@ -217,7 +252,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu clearTimeout(programmaticScrollTimeoutRef.current); } }; - }, []); + }, [pauseManualScrollFollow]); return { containerRef, diff --git a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx index 269180f1c..62ae00f54 100644 --- a/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-karaoke-lyrics.tsx @@ -150,7 +150,9 @@ export const SynchronizedKaraokeLyrics = ({ eventCreationTime: options?.eventCreationTime ?? Date.now(), forceResync: options?.forceResync ?? false, }); - lastSyncedTimeRef.current = timeInMs; + const eventCreationTime = options?.eventCreationTime ?? Date.now(); + const interpolatedOffsetMs = isPlaying ? Date.now() - eventCreationTime : 0; + lastSyncedTimeRef.current = timeInMs + interpolatedOffsetMs; }, [rebuildLyricsData, reset, tick], ); @@ -267,6 +269,8 @@ export const SynchronizedKaraokeLyrics = ({ } if (isSeek) { + resumeAutoscroll(); + resumeEngineAutoscroll(); syncAtTime(timeInMs, true, { eventCreationTime: playbackAnchorRef.current.eventCreationTime, forceReset: true, @@ -276,7 +280,7 @@ export const SynchronizedKaraokeLyrics = ({ }); return unsubscribe; - }, [delayMsRef, syncAtTime, updatePlaybackAnchor]); + }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, syncAtTime, updatePlaybackAnchor]); const getOverlayText = ( overlayLyrics: null | SynchronizedLyricsData | undefined, diff --git a/src/renderer/features/lyrics/synchronized-lyrics.tsx b/src/renderer/features/lyrics/synchronized-lyrics.tsx index 424e49bff..3f02ae443 100644 --- a/src/renderer/features/lyrics/synchronized-lyrics.tsx +++ b/src/renderer/features/lyrics/synchronized-lyrics.tsx @@ -126,6 +126,8 @@ export const SynchronizedLyrics = ({ const timeInMs = timestamp * 1000 + delayMsRef.current; if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { + resumeAutoscroll(); + resumeEngineAutoscroll(); syncAtTime(timeInMs, true, true); } else { syncAtTime(timeInMs, true); @@ -135,7 +137,7 @@ export const SynchronizedLyrics = ({ }; rafRef.current = requestAnimationFrame(runTick); - }, [delayMsRef, stopRaf, syncAtTime]); + }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, stopRaf, syncAtTime]); const syncFromCurrentTimestamp = useCallback(() => { const timestamp = useTimestampStoreBase.getState().timestamp; @@ -205,12 +207,14 @@ export const SynchronizedLyrics = ({ } if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { + resumeAutoscroll(); + resumeEngineAutoscroll(); syncAtTime(timeInMs, true, true); } }); return unsubscribe; - }, [delayMsRef, syncAtTime]); + }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, syncAtTime]); const handleContainerClick = useCallback( (event: React.MouseEvent) => {