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 64f89e50..c1933836 100644 --- a/apps/readest-app/src/__tests__/services/tts-controller.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-controller.test.ts @@ -473,6 +473,27 @@ describe('TTSController', () => { expect(controller.state).toBe('stopped'); }); + test('error preserves state for AbortError (DOMException-style)', () => { + // iOS audio.play() and AbortSignal-aware fetches reject with a DOMException + // whose name is 'AbortError'. Treating it as a real error desyncs the state + // machine: subsequent rate changes see state !== 'playing' and skip the + // stop+start cycle, and #speak's auto-forward gate fails. + controller.state = 'playing'; + const abort = new Error('The operation was aborted.'); + abort.name = 'AbortError'; + controller.error(abort); + expect(controller.state).toBe('playing'); + }); + + test('error preserves state for our internal Aborted message', () => { + // EdgeTTSClient and NativeTTSClient resolve the inner promise with + // { code: 'error', message: 'Aborted' } on signal abort; if that bubbles + // through any catch path it must not flip state to 'stopped'. + controller.state = 'playing'; + controller.error(new Error('Aborted')); + expect(controller.state).toBe('playing'); + }); + test('play calls start when not playing', () => { controller.state = 'stopped'; const startSpy = vi.spyOn(controller, 'start').mockResolvedValue(); diff --git a/apps/readest-app/src/services/tts/TTSController.ts b/apps/readest-app/src/services/tts/TTSController.ts index d19ce1d2..446b4ce0 100644 --- a/apps/readest-app/src/services/tts/TTSController.ts +++ b/apps/readest-app/src/services/tts/TTSController.ts @@ -558,6 +558,16 @@ export class TTSController extends EventTarget { } error(e: unknown) { + // AbortError is expected during normal stop/restart cycles (rate change, + // forward/backward, voice change) — on iOS especially, the in-flight + // audio.play() promise rejects with AbortError after audio.src is reset, + // and that rejection can leak through one of the .catch chains. Letting it + // flip state to 'stopped' desyncs the state machine: handleSetRate's + // `state === 'playing'` check then falls through to a no-op, and #speak's + // auto-forward gate skips advancing to the next paragraph. + if (e instanceof Error && (e.name === 'AbortError' || e.message === 'Aborted')) { + return; + } console.error(e); this.state = 'stopped'; }