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();
}