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 words = lineElement.querySelectorAll<HTMLElement>(
'.karaoke-overlay-word[data-duration]',
);
const words = lineElement.querySelectorAll<HTMLElement>('.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 => {
@@ -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,
@@ -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,
@@ -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<HTMLDivElement>) => {