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],
);