From 9c273d79fc7615f91c4c8c84c99b027c9e5a2168 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 20 Apr 2026 23:57:20 +0800 Subject: [PATCH] fix(tts): fixed race condition on pause/resume, closes #3825 (#3905) --- .../__tests__/services/tts-controller.test.ts | 33 +++++++++++++++++++ .../src/services/tts/TTSController.ts | 7 +++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/readest-app/src/__tests__/services/tts-controller.test.ts b/apps/readest-app/src/__tests__/services/tts-controller.test.ts index 1536dd5b..64f89e50 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -570,6 +570,39 @@ describe('TTSController', () => { }); }); + describe('start', () => { + test('uses tts.resume() not tts.start() when state is stopped (play/pause race fix)', async () => { + // Repro: `forward()` transitions state to 'stopped' transiently between its + // `await this.stop()` and the follow-up navigation. If the user taps play + // in that window, `start()` previously called `tts.start()` — which resets + // the TTS list to position 0 (section beginning) instead of resuming the + // current paragraph. The fix: always use `tts.resume()` (which itself + // falls back to `next()` on a fresh TTS), so there's no way `start()` + // ever rewinds to the top of a section. + await controller.initViewTTS(0); + + const ttsStartMock = vi.fn().mockReturnValue('section-start'); + const ttsResumeMock = vi.fn().mockReturnValue('current'); + const tts = mockView.tts as unknown as { + start: typeof ttsStartMock; + resume: typeof ttsResumeMock; + next: ReturnType; + prev: ReturnType; + }; + tts.start = ttsStartMock; + tts.resume = ttsResumeMock; + tts.next = vi.fn().mockReturnValue(undefined); + tts.prev = vi.fn(); + + // Simulate the race: state is 'stopped' (transient during forward()) + controller.state = 'stopped'; + await controller.start(); + + expect(ttsResumeMock).toHaveBeenCalled(); + expect(ttsStartMock).not.toHaveBeenCalled(); + }); + }); + describe('forward and backward', () => { test('forward sets forward-paused state when not playing', async () => { // Set up controller with a mock tts on the view diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index bb179293..d19ce1d2 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -407,7 +407,12 @@ export class TTSController extends EventTarget { async start() { await this.initViewTTS(); - const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start(); + // Always resume from the current list position instead of calling tts.start(). + // tts.start() resets the TTS list to position 0 (section beginning), which is + // wrong when state transiently becomes 'stopped' during forward()/backward() + // — a fast play tap in that window would otherwise jump back to section start. + // tts.resume() falls back to tts.next() on a fresh TTS, so it's safe at init. + const ssml = this.view.tts?.resume(); if (this.state.includes('paused')) { this.resume(); }