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:
parent
b679bf736c
commit
502c98f11f
2 changed files with 58 additions and 3 deletions
|
|
@ -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' });
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue