fix scroll follow pause on manual scroll

This commit is contained in:
jeffvli
2026-07-10 18:24:17 -07:00
parent aea5a880ea
commit 9b3a47a27f
4 changed files with 75 additions and 33 deletions
@@ -157,9 +157,7 @@ const collectLineParts = (lineElement: HTMLElement): PartData[] => {
}; };
const collectOverlayParts = (lineElement: HTMLElement): PartData[] => { const collectOverlayParts = (lineElement: HTMLElement): PartData[] => {
const words = lineElement.querySelectorAll<HTMLElement>( const words = lineElement.querySelectorAll<HTMLElement>('.karaoke-overlay-word[data-duration]');
'.karaoke-overlay-word[data-duration]',
);
return Array.from(words).map((element) => ({ return Array.from(words).map((element) => ({
animationStartTimeMs: Number.POSITIVE_INFINITY, animationStartTimeMs: Number.POSITIVE_INFINITY,
@@ -263,11 +261,7 @@ const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: nu
part.isAnimating = true; part.isAnimating = true;
}; };
const animateWordParts = ( const animateWordParts = (parts: PartData[], interpolatedTimeSec: number, now: number): void => {
parts: PartData[],
interpolatedTimeSec: number,
now: number,
): void => {
for (const part of parts) { for (const part of parts) {
const partEnd = part.time + part.duration; const partEnd = part.time + part.duration;
@@ -703,8 +697,7 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
const ss = state.scroll; const ss = state.scroll;
const scrollPausedByUser = ss.scrollResumeTime >= now; const scrollPausedByUser = ss.scrollResumeTime >= now;
const canAutoscroll = const canAutoscroll = follow && !scrollPausedByUser;
follow && (!scrollPausedByUser || ss.pendingScroll || ss.scrollPos === -1);
if (canAutoscroll) { if (canAutoscroll) {
if (activeElems.length === 0 && lines.length > 0) { if (activeElems.length === 0 && lines.length > 0) {
@@ -760,14 +753,20 @@ export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions):
if (ss.wasUserScrolling && ss.scrollResumeTime < now) { if (ss.wasUserScrolling && ss.scrollResumeTime < now) {
ss.wasUserScrolling = false; ss.wasUserScrolling = false;
ss.pendingScroll = true;
} }
return activeLineIndex; return activeLineIndex;
}; };
export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => { export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => {
state.scroll.wasUserScrolling = true; const ss = state.scroll;
state.scroll.scrollResumeTime = Date.now() + pauseDurationMs; cancelActiveLyricsScroll(ss);
ss.programmaticScrollUntil = 0;
ss.skipScrolls = 0;
ss.skipScrollsDecayTimes = [];
ss.wasUserScrolling = true;
ss.scrollResumeTime = Date.now() + pauseDurationMs;
}; };
export const resumeLyricsAutoscroll = (state: AnimEngineState): void => { export const resumeLyricsAutoscroll = (state: AnimEngineState): void => {
@@ -1,5 +1,5 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { import {
animateLyricsScrollTo, animateLyricsScrollTo,
@@ -22,6 +22,8 @@ const utils = isElectron() ? window.api.utils : null;
const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null; const mpris = isElectron() && utils?.isLinux() ? window.api.mpris : null;
export const LYRICS_SCROLL_CONTAINER_ID = 'sychronized-lyrics-scroll-container'; 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) => { export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: number) => {
const playbackType = usePlaybackType(); const playbackType = usePlaybackType();
@@ -129,6 +131,19 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
resumeLyricsAutoscroll(scrollAnimStateRef.current); 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( const containerStyle = useMemo(
() => () =>
({ ({
@@ -170,13 +185,37 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
delayMsRef.current = newOffset; delayMsRef.current = newOffset;
}, [offsetMs]); }, [offsetMs]);
useEffect(() => { useLayoutEffect(() => {
const container = const container = containerRef.current;
containerRef.current || if (!container) {
(document.getElementById(LYRICS_SCROLL_CONTAINER_ID) as HTMLElement); return;
if (!container) return; }
const handleWheel = (event: WheelEvent) => {
if (event.deltaX === 0 && event.deltaY === 0) {
return;
}
pauseManualScrollFollow();
};
const handleTouchStart = () => {
pauseManualScrollFollow();
};
const handleScroll = () => { 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)) { if (shouldSkipLyricsScrollEvent(scrollAnimStateRef.current)) {
return; return;
} }
@@ -193,22 +232,18 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
return; return;
} }
userScrollingRef.current = true; pauseManualScrollFollow();
handleLyricsUserScroll(scrollAnimStateRef.current);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => {
userScrollingRef.current = false;
}, 3000);
}; };
container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('scroll', handleScroll, { passive: true }); container.addEventListener('scroll', handleScroll, { passive: true });
return () => { return () => {
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('scroll', handleScroll); container.removeEventListener('scroll', handleScroll);
if (scrollTimeoutRef.current) { if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current); clearTimeout(scrollTimeoutRef.current);
} }
@@ -217,7 +252,7 @@ export const useSynchronizedLyricsBase = (settingsKey = 'default', offsetMs?: nu
clearTimeout(programmaticScrollTimeoutRef.current); clearTimeout(programmaticScrollTimeoutRef.current);
} }
}; };
}, []); }, [pauseManualScrollFollow]);
return { return {
containerRef, containerRef,
@@ -150,7 +150,9 @@ export const SynchronizedKaraokeLyrics = ({
eventCreationTime: options?.eventCreationTime ?? Date.now(), eventCreationTime: options?.eventCreationTime ?? Date.now(),
forceResync: options?.forceResync ?? false, 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], [rebuildLyricsData, reset, tick],
); );
@@ -267,6 +269,8 @@ export const SynchronizedKaraokeLyrics = ({
} }
if (isSeek) { if (isSeek) {
resumeAutoscroll();
resumeEngineAutoscroll();
syncAtTime(timeInMs, true, { syncAtTime(timeInMs, true, {
eventCreationTime: playbackAnchorRef.current.eventCreationTime, eventCreationTime: playbackAnchorRef.current.eventCreationTime,
forceReset: true, forceReset: true,
@@ -276,7 +280,7 @@ export const SynchronizedKaraokeLyrics = ({
}); });
return unsubscribe; return unsubscribe;
}, [delayMsRef, syncAtTime, updatePlaybackAnchor]); }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, syncAtTime, updatePlaybackAnchor]);
const getOverlayText = ( const getOverlayText = (
overlayLyrics: null | SynchronizedLyricsData | undefined, overlayLyrics: null | SynchronizedLyricsData | undefined,
@@ -126,6 +126,8 @@ export const SynchronizedLyrics = ({
const timeInMs = timestamp * 1000 + delayMsRef.current; const timeInMs = timestamp * 1000 + delayMsRef.current;
if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) {
resumeAutoscroll();
resumeEngineAutoscroll();
syncAtTime(timeInMs, true, true); syncAtTime(timeInMs, true, true);
} else { } else {
syncAtTime(timeInMs, true); syncAtTime(timeInMs, true);
@@ -135,7 +137,7 @@ export const SynchronizedLyrics = ({
}; };
rafRef.current = requestAnimationFrame(runTick); rafRef.current = requestAnimationFrame(runTick);
}, [delayMsRef, stopRaf, syncAtTime]); }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, stopRaf, syncAtTime]);
const syncFromCurrentTimestamp = useCallback(() => { const syncFromCurrentTimestamp = useCallback(() => {
const timestamp = useTimestampStoreBase.getState().timestamp; const timestamp = useTimestampStoreBase.getState().timestamp;
@@ -205,12 +207,14 @@ export const SynchronizedLyrics = ({
} }
if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) { if (Math.abs(timeInMs - lastSyncedTimeRef.current) > SEEK_DETECT_THRESHOLD_MS) {
resumeAutoscroll();
resumeEngineAutoscroll();
syncAtTime(timeInMs, true, true); syncAtTime(timeInMs, true, true);
} }
}); });
return unsubscribe; return unsubscribe;
}, [delayMsRef, syncAtTime]); }, [delayMsRef, resumeAutoscroll, resumeEngineAutoscroll, syncAtTime]);
const handleContainerClick = useCallback( const handleContainerClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLDivElement>) => {