From fed8ab7b67dcfd9f17d3908ba6ec2010fd7eb0d5 Mon Sep 17 00:00:00 2001 From: Roy Zhu Date: Wed, 13 May 2026 10:44:10 -0400 Subject: [PATCH] fix(tts): restore cross-section auto-page-turn during TTS playback (#4148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When TTS playback crosses a section boundary, the page would stay on the last page of the previous chapter while audio continued reading the next chapter — leaving the user stuck behind the "back-to-TTS" button. Two compounding issues since the paginator adjacent-section preloading landed: 1. `handleSectionChange` called `view.renderer.goTo(resolved)` without awaiting. `TTSController.#initTTSForSection` does `await this.onSectionChange?.(sectionIndex)` precisely so the view can finish navigating before audio of the new section starts, but the missing await defeated that contract. 2. `handleHighlightMark` returned silently on a cross-section mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the renderer.goTo above completed only partially — which can happen on the new paginator when the target section is already loaded as an adjacent view and the post-goTo state appears reused without a visible page flip — there was no second chance to drag the view to the TTS cfi. Fix: - Await `view.renderer.goTo` in `handleSectionChange`. - In `handleHighlightMark`, run the cross-section branch *before* the `followingTTSLocationRef` check and call `view.goTo(cfi)` directly, stamping `sectionChangingTimestampRef` so the back-to-TTS button stays suppressed while progress.location catches up. Skip only when the user is actively selecting text. Adds unit tests covering both the cross-section navigation path and the in-section scrollToAnchor path. --- .../__tests__/hooks/useTTSControl.test.tsx | 68 +++++++++++++++++++ .../src/app/reader/hooks/useTTSControl.ts | 32 ++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx index f746cf30..192609f7 100644 --- a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx @@ -35,6 +35,7 @@ const mockView = { getCFI: vi.fn().mockReturnValue('cfi'), deselect: vi.fn(), resolveNavigation: vi.fn(), + goTo: vi.fn(), history: { back: vi.fn(), forward: vi.fn() }, tts: { from: vi.fn().mockReturnValue('hello'), @@ -241,3 +242,70 @@ describe('useTTSControl concurrent tts-speak events', () => { }); }); }); + +describe('useTTSControl handleHighlightMark cross-section navigation', () => { + beforeEach(() => { + ttsControllerInstances.length = 0; + pendingInitResolvers.length = 0; + mockView.renderer.scrollToAnchor.mockClear(); + mockView.renderer.goTo.mockClear(); + mockView.goTo.mockClear(); + mockView.resolveCFI.mockReset(); + mockViewSettings.ttsLocation = null; + }); + + afterEach(() => { + cleanup(); + }); + + const setupAndCaptureHighlightHandler = async () => { + render(); + + await act(async () => { + const p = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!(); + await p; + }); + + // Let the listener-registration useEffect run. + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + + const controller = ttsControllerInstances[0] as { + addEventListener: { mock: { calls: [string, (e: Event) => void][] } }; + }; + const calls = controller.addEventListener.mock.calls; + const entry = calls.find(([name]) => name === 'tts-highlight-mark'); + if (!entry) throw new Error('tts-highlight-mark listener was not registered'); + return entry[1]; + }; + + it('navigates to the cfi via view.goTo when TTS crosses into a new section', async () => { + const handler = await setupAndCaptureHighlightHandler(); + + // primaryIndex is 0 (current view section). Make the TTS cfi resolve to section 1. + mockView.resolveCFI.mockReturnValue({ index: 1, anchor: () => new Range() }); + + await act(async () => { + handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/8!/4/2)' } })); + }); + + expect(mockView.goTo).toHaveBeenCalledWith('epubcfi(/6/8!/4/2)'); + expect(mockView.renderer.scrollToAnchor).not.toHaveBeenCalled(); + }); + + it('keeps in-section behaviour: scrolls via renderer without navigating', async () => { + const handler = await setupAndCaptureHighlightHandler(); + + mockView.resolveCFI.mockReturnValue({ index: 0, anchor: () => new Range() }); + + await act(async () => { + handler(new CustomEvent('tts-highlight-mark', { detail: { cfi: 'epubcfi(/6/4!/4/2)' } })); + }); + + expect(mockView.renderer.scrollToAnchor).toHaveBeenCalledTimes(1); + expect(mockView.goTo).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index 7868f5ca..564c27e7 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -187,13 +187,6 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp viewSettings.ttsLocation = cfi; setViewSettings(bookKey, viewSettings); - if (!followingTTSLocationRef.current) return; - - const docs = view.renderer.getContents(); - if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) { - return; - } - const hlContents = view.renderer.getContents(); const hlPrimaryIdx = view.renderer.primaryIndex; const { doc, index: viewSectionIndex } = (hlContents.find((x) => x.index === hlPrimaryIdx) ?? @@ -204,6 +197,27 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi); if (viewSectionIndex !== ttsSectionIndex) { + // TTS crossed into a new section before the view caught up. The + // `await onSectionChange` path in TTSController fires renderer.goTo + // via handleSectionChange, but the new paginator's #goTo can resolve + // before the visible page actually flips when the target section is + // already preloaded as an adjacent view — leaving the user stuck on + // the last page of the previous chapter while audio continues. Drive + // navigation from the highlight cfi directly, stamping the timestamp + // so the "back-to-TTS" button stays suppressed while progress.location + // catches up. Skip only when the user is actively selecting text. + if (hlContents.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) { + return; + } + sectionChangingTimestampRef.current = Date.now(); + followingTTSLocationRef.current = true; + view.goTo?.(cfi); + return; + } + + if (!followingTTSLocationRef.current) return; + + if (hlContents.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) { return; } @@ -363,7 +377,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) return; sectionChangingTimestampRef.current = Date.now(); const resolved = view.resolveNavigation(sectionIndex); - view.renderer.goTo?.(resolved); + // Await so TTSController's `await onSectionChange` doesn't proceed to + // speak the new section before the view has finished navigating to it. + await view.renderer.goTo?.(resolved); }, [bookKey, getView], );