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

View file

@ -146,6 +146,7 @@ interface TTSContextType extends TTSPlaybackState {
setSpeedAndRestart: (speed: number) => void;
setAudioPlayerSpeedAndRestart: (speed: number) => void;
setVoiceAndRestart: (voice: string) => void;
clearSegmentCaches: () => void;
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
registerEpubLocationWalker: (walker: EpubRenderedLocationWalker | null) => void;
@ -2896,6 +2897,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentWordIndex(null);
}, [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
*
@ -3107,6 +3118,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
clearSegmentCaches,
skipToLocation,
registerLocationChangeHandler,
registerEpubLocationWalker,
@ -3137,6 +3149,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
clearSegmentCaches,
skipToLocation,
registerLocationChangeHandler,
registerEpubLocationWalker,