refactor(tts): add segment cache clearing and improve sidebar scroll handling

Introduce clearSegmentCaches to TTS context for explicit cache invalidation after segment clearing. Enhance SegmentsSidebar scroll logic to better distinguish user-initiated and programmatic scrolls, improving UX during segment updates.
This commit is contained in:
Richard R 2026-05-20 04:59:09 -06:00
parent b679bf736c
commit 502c98f11f
2 changed files with 58 additions and 3 deletions

View file

@ -185,6 +185,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
isPlaying, isPlaying,
playFromSegment, playFromSegment,
activeReaderType, activeReaderType,
clearSegmentCaches,
} = useTTS(); } = useTTS();
const { const {
providerRef, providerRef,
@ -241,6 +242,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
const didAutoScrollOnOpenRef = useRef(false); const didAutoScrollOnOpenRef = useRef(false);
const userScrollUntilMsRef = useRef(0);
const programmaticScrollUntilMsRef = useRef(0);
const lastSegmentRefreshKeyRef = useRef('');
const segmentsQueryKey = useMemo( const segmentsQueryKey = useMemo(
() => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const, () => [SEGMENTS_MANIFEST_QUERY_KEY, documentId] as const,
[documentId], [documentId],
@ -249,8 +253,6 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const segmentsQuery = useInfiniteQuery({ const segmentsQuery = useInfiniteQuery({
queryKey: segmentsQueryKey, queryKey: segmentsQueryKey,
enabled: isOpen && !!documentId, enabled: isOpen && !!documentId,
refetchInterval: isOpen ? 2500 : false,
refetchIntervalInBackground: false,
initialPageParam: null as string | null, initialPageParam: null as string | null,
queryFn: async ({ pageParam, signal }) => { queryFn: async ({ pageParam, signal }) => {
if (!documentId) { if (!documentId) {
@ -321,6 +323,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
return payload; return payload;
}, },
onSuccess: async (payload) => { onSuccess: async (payload) => {
clearSegmentCaches();
if (payload?.warning) { if (payload?.warning) {
toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`); toast.error(`Segments cleared, but audio cleanup was partial: ${payload.warning}`);
} else if (payload) { } else if (payload) {
@ -354,20 +357,55 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
await clearSegments(); await clearSegments();
}, [documentId, isClearingSegments, clearSegments]); }, [documentId, isClearingSegments, clearSegments]);
useEffect(() => {
if (!isOpen || !isPlaying) return;
const locationKey = activeReaderType === 'epub'
? String(currDocPage)
: String(currDocPageNumber);
const refreshKey = `${activeReaderType}|${locationKey}|${currentSentenceIndex}`;
if (lastSegmentRefreshKeyRef.current === refreshKey) return;
lastSegmentRefreshKeyRef.current = refreshKey;
void refetchManifest();
}, [
isOpen,
isPlaying,
activeReaderType,
currDocPage,
currDocPageNumber,
currentSentenceIndex,
refetchManifest,
]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
const node = listRef.current; const node = listRef.current;
if (!node) return; if (!node) return;
const markUserScrollActive = () => {
userScrollUntilMsRef.current = Date.now() + 1200;
};
const onScroll = () => { const onScroll = () => {
if (Date.now() > programmaticScrollUntilMsRef.current) {
markUserScrollActive();
}
if (!hasMoreManifestPages || isLoadingMoreManifest) return; if (!hasMoreManifestPages || isLoadingMoreManifest) return;
const distance = node.scrollHeight - node.scrollTop - node.clientHeight; const distance = node.scrollHeight - node.scrollTop - node.clientHeight;
if (distance > 280) return; if (distance > 280) return;
void fetchNextPage(); void fetchNextPage();
}; };
const onWheel = () => markUserScrollActive();
const onTouchMove = () => markUserScrollActive();
node.addEventListener('scroll', onScroll); node.addEventListener('scroll', onScroll);
return () => node.removeEventListener('scroll', onScroll); node.addEventListener('wheel', onWheel, { passive: true });
node.addEventListener('touchmove', onTouchMove, { passive: true });
return () => {
node.removeEventListener('scroll', onScroll);
node.removeEventListener('wheel', onWheel);
node.removeEventListener('touchmove', onTouchMove);
};
}, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]); }, [isOpen, hasMoreManifestPages, isLoadingMoreManifest, fetchNextPage]);
const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => { const handleSelectVariant = useCallback(async (settings: TTSSegmentSettings | null) => {
@ -553,10 +591,12 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const container = listRef.current; const container = listRef.current;
if (!container) return; if (!container) return;
if (Date.now() < userScrollUntilMsRef.current) return;
const activeRow = container.querySelector<HTMLElement>('[data-active-segment="true"]'); const activeRow = container.querySelector<HTMLElement>('[data-active-segment="true"]');
if (!activeRow) return; if (!activeRow) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
programmaticScrollUntilMsRef.current = Date.now() + 300;
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
didAutoScrollOnOpenRef.current = true; didAutoScrollOnOpenRef.current = true;
}); });
@ -568,6 +608,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const root = listRef.current; const root = listRef.current;
if (!root) return; if (!root) return;
if (Date.now() < userScrollUntilMsRef.current) return;
const activeRow = root.querySelector<HTMLElement>('[data-active-segment="true"]'); const activeRow = root.querySelector<HTMLElement>('[data-active-segment="true"]');
if (!activeRow) return; if (!activeRow) return;
@ -576,6 +617,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return; if (isElementFullyVisibleWithinContainer(activeRow, scrollContainer)) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
programmaticScrollUntilMsRef.current = Date.now() + 300;
activeRow.scrollIntoView({ block: 'center', behavior: 'auto' }); activeRow.scrollIntoView({ block: 'center', behavior: 'auto' });
}); });
}, [ }, [

View file

@ -146,6 +146,7 @@ interface TTSContextType extends TTSPlaybackState {
setSpeedAndRestart: (speed: number) => void; setSpeedAndRestart: (speed: number) => void;
setAudioPlayerSpeedAndRestart: (speed: number) => void; setAudioPlayerSpeedAndRestart: (speed: number) => void;
setVoiceAndRestart: (voice: string) => void; setVoiceAndRestart: (voice: string) => void;
clearSegmentCaches: () => void;
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void; skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void; registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void;
@ -2896,6 +2897,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentWordIndex(null); setCurrentWordIndex(null);
}, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]); }, [abortAudio, clearWarmAudioCache, invalidatePlaybackRun, clearPendingEpubJump, bumpEpubPreloadGeneration]);
const clearSegmentCaches = useCallback(() => {
// Keep the current viewport/sentence list intact, but force all audio/manifest
// state to be re-resolved after a server-side clear.
abortAudio(true);
segmentManifestCacheRef.current.clear();
sentenceAlignmentCacheRef.current.clear();
setCurrentSentenceAlignment(undefined);
setCurrentWordIndex(null);
}, [abortAudio]);
/** /**
* Stops the current audio playback and starts playing from a specified index * Stops the current audio playback and starts playing from a specified index
* *
@ -3107,6 +3118,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart, setSpeedAndRestart,
setAudioPlayerSpeedAndRestart, setAudioPlayerSpeedAndRestart,
setVoiceAndRestart, setVoiceAndRestart,
clearSegmentCaches,
skipToLocation, skipToLocation,
registerLocationChangeHandler, registerLocationChangeHandler,
registerEpubLocationWalker, registerEpubLocationWalker,
@ -3137,6 +3149,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart, setSpeedAndRestart,
setAudioPlayerSpeedAndRestart, setAudioPlayerSpeedAndRestart,
setVoiceAndRestart, setVoiceAndRestart,
clearSegmentCaches,
skipToLocation, skipToLocation,
registerLocationChangeHandler, registerLocationChangeHandler,
registerEpubLocationWalker, registerEpubLocationWalker,