forked from akai/readest
fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
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.
This commit is contained in:
@@ -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('<speak>hello</speak>'),
|
||||
@@ -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(<Harness />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user