diff --git a/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx
new file mode 100644
index 00000000..f746cf30
--- /dev/null
+++ b/apps/readest-app/src/__tests__/hooks/useTTSControl.test.tsx
@@ -0,0 +1,243 @@
+import { act, cleanup, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// --- Dependency mocks (must be set up before importing the hook) ---
+
+vi.mock('@/context/EnvContext', () => ({
+ useEnv: () => ({
+ appService: { isIOSApp: false, isMobile: false },
+ envConfig: {},
+ }),
+}));
+
+vi.mock('@/context/AuthContext', () => ({
+ useAuth: () => ({ user: null }),
+}));
+
+vi.mock('@/store/themeStore', () => ({
+ useThemeStore: () => ({ isDarkMode: false }),
+}));
+
+const mockView = {
+ book: { primaryLanguage: 'en', sections: [{ id: 0 }] },
+ renderer: {
+ getContents: () => [{ index: 0, doc: document as unknown as Document }],
+ scrollToAnchor: vi.fn(),
+ primaryIndex: 0,
+ scrolled: false,
+ nextSection: vi.fn(),
+ start: 0,
+ end: 0,
+ sideProp: 'height',
+ goTo: vi.fn(),
+ },
+ resolveCFI: vi.fn().mockReturnValue({ index: 0, anchor: () => new Range() }),
+ getCFI: vi.fn().mockReturnValue('cfi'),
+ deselect: vi.fn(),
+ resolveNavigation: vi.fn(),
+ history: { back: vi.fn(), forward: vi.fn() },
+ tts: {
+ from: vi.fn().mockReturnValue('hello'),
+ start: vi.fn().mockReturnValue('hello'),
+ getLastRange: vi.fn().mockReturnValue(null),
+ highlight: vi.fn(),
+ },
+};
+
+const mockProgress = {
+ location: { start: { cfi: '' }, end: { cfi: '' } },
+ index: 0,
+ range: null,
+ sectionLabel: '',
+};
+
+const mockViewSettings = {
+ ttsLocation: null as string | null,
+ ttsRate: 1,
+ ttsHighlightOptions: { style: 'highlight', color: '#ffff00' },
+ isEink: false,
+ showTTSBar: false,
+ ttsMediaMetadata: 'sentence',
+ translationEnabled: false,
+ ttsReadAloudText: 'source',
+};
+
+const mockBookData = {
+ isFixedLayout: false,
+ book: { primaryLanguage: 'en', title: 'T', author: 'A', coverImageUrl: '' },
+};
+
+vi.mock('@/store/readerStore', () => {
+ const store = {
+ hoveredBookKey: null,
+ getView: () => mockView,
+ getProgress: () => mockProgress,
+ getViewSettings: () => mockViewSettings,
+ setViewSettings: vi.fn(),
+ setTTSEnabled: vi.fn(),
+ };
+ const useReaderStore = () => store;
+ useReaderStore.getState = () => store;
+ return { useReaderStore };
+});
+
+vi.mock('@/store/bookDataStore', () => ({
+ useBookDataStore: () => ({
+ getBookData: () => mockBookData,
+ }),
+}));
+
+vi.mock('@/store/proofreadStore', () => ({
+ useProofreadStore: () => ({
+ getMergedRules: () => [],
+ }),
+}));
+
+vi.mock('@/services/transformers/proofread', () => ({
+ proofreadTransformer: {
+ transform: vi.fn(async (ctx: { content: string }) => ctx.content),
+ },
+}));
+
+vi.mock('@/hooks/useTranslation', () => ({
+ useTranslation: () => (s: string) => s,
+}));
+
+// Track TTSController instantiations — this is the assertion target.
+const ttsControllerInstances: unknown[] = [];
+// Gate init() calls so that handleTTSSpeak stays suspended inside an `await`.
+// This is the exact point where a second concurrent invocation would otherwise
+// race ahead and construct a second TTSController. The test releases all
+// pending resolvers once both dispatches have had a chance to interleave.
+const pendingInitResolvers: Array<() => void> = [];
+
+vi.mock('@/services/tts', () => ({
+ TTSController: vi.fn().mockImplementation(function (this: Record) {
+ Object.assign(this, {
+ init: vi.fn().mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ pendingInitResolvers.push(() => resolve());
+ }),
+ ),
+ initViewTTS: vi.fn().mockResolvedValue(undefined),
+ updateHighlightOptions: vi.fn(),
+ setLang: vi.fn(),
+ setRate: vi.fn(),
+ setVoice: vi.fn(),
+ setTargetLang: vi.fn(),
+ speak: vi.fn(),
+ pause: vi.fn().mockResolvedValue(undefined),
+ resume: vi.fn().mockResolvedValue(undefined),
+ stop: vi.fn().mockResolvedValue(undefined),
+ shutdown: vi.fn().mockResolvedValue(undefined),
+ forward: vi.fn().mockResolvedValue(undefined),
+ backward: vi.fn().mockResolvedValue(undefined),
+ getVoices: vi.fn().mockResolvedValue([]),
+ getVoiceId: vi.fn().mockReturnValue(''),
+ state: 'idle',
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ });
+ ttsControllerInstances.push(this);
+ }),
+}));
+
+vi.mock('@/libs/mediaSession', () => ({
+ TauriMediaSession: class {},
+}));
+
+vi.mock('@/utils/ssml', () => ({
+ genSSMLRaw: vi.fn((s: string) => `${s}`),
+ parseSSMLLang: vi.fn(() => 'en'),
+}));
+
+vi.mock('@/utils/throttle', () => ({
+ throttle: unknown>(fn: T) => fn,
+}));
+
+vi.mock('@/utils/cfi', () => ({
+ isCfiInLocation: () => false,
+}));
+
+vi.mock('@/utils/misc', () => ({
+ getLocale: () => 'en',
+}));
+
+vi.mock('@/utils/ttsMetadata', () => ({
+ buildTTSMediaMetadata: () => ({
+ shouldUpdate: false,
+ title: '',
+ artist: '',
+ album: '',
+ }),
+}));
+
+vi.mock('@/utils/bridge', () => ({
+ invokeUseBackgroundAudio: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock('@/utils/ttsTime', () => ({
+ estimateTTSTime: () => ({
+ chapterRemainingSec: 0,
+ bookRemainingSec: 0,
+ finishAtTimestamp: 0,
+ }),
+}));
+
+vi.mock('@/app/reader/hooks/useTTSMediaSession', () => ({
+ useTTSMediaSession: () => ({
+ mediaSessionRef: { current: null },
+ unblockAudio: vi.fn(),
+ releaseUnblockAudio: vi.fn(),
+ initMediaSession: vi.fn().mockResolvedValue(undefined),
+ deinitMediaSession: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+// Imports must come AFTER vi.mock calls so they pick up the mocked modules.
+import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
+import { eventDispatcher } from '@/utils/event';
+
+const Harness = () => {
+ useTTSControl({ bookKey: 'book-1' });
+ return null;
+};
+
+describe('useTTSControl concurrent tts-speak events', () => {
+ beforeEach(() => {
+ ttsControllerInstances.length = 0;
+ pendingInitResolvers.length = 0;
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('creates only one TTSController when two tts-speak events fire back-to-back', async () => {
+ render();
+
+ await act(async () => {
+ // Kick off both dispatches without awaiting — this models rapid clicks
+ // where the second click arrives while the first is still inside its
+ // initial awaits (initMediaSession / backgroundAudio / init()).
+ const p1 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
+ const p2 = eventDispatcher.dispatch('tts-speak', { bookKey: 'book-1' });
+
+ // Let both invocations drain microtasks and reach their gated await.
+ // Without the single-flight guard in handleTTSSpeak, both invocations
+ // would construct a TTSController here and both would be queued in
+ // pendingInitResolvers.
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+
+ // The assertion that matters: exactly one controller was constructed.
+ expect(ttsControllerInstances.length).toBe(1);
+
+ // Release any pending init() promises so the dispatch chain can unwind
+ // cleanly (otherwise the act() would never settle).
+ while (pendingInitResolvers.length > 0) pendingInitResolvers.shift()!();
+ await Promise.all([p1, p2]);
+ });
+ });
+});
diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts
index b213bc18..7868f5ca 100644
--- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts
+++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts
@@ -50,6 +50,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const sectionChangingTimestampRef = useRef(0);
const previousSectionLabelRef = useRef(undefined);
const ttsControllerRef = useRef(null);
+ const isStartingTTSRef = useRef(false);
const [ttsController, setTtsController] = useState(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
@@ -422,103 +423,112 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, index, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
-
- const view = getView(bookKey);
- const progress = getProgress(bookKey);
- const viewSettings = getViewSettings(bookKey);
- const bookData = getBookData(bookKey);
- const { location } = progress || {};
- if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
- const ttsSpeakRange = range as Range | null;
- let ttsFromRange = ttsSpeakRange;
- let ttsFromIndex = typeof index === 'number' ? index : null;
- if (!ttsFromRange && viewSettings.ttsLocation) {
- const ttsCfi = viewSettings.ttsLocation;
- if (isCfiInLocation(ttsCfi, location)) {
- const { index, anchor } = view.resolveCFI(ttsCfi);
- const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
- if (doc) {
- ttsFromRange = anchor(doc);
- ttsFromIndex = index;
- }
- }
- }
-
- if (!ttsFromIndex) {
- ttsFromIndex = progress.index;
- }
-
- if (!ttsFromRange && !bookData.isFixedLayout) {
- ttsFromRange = progress.range;
- }
-
- const currentSection = view.renderer.getContents().find((x) => x.index === ttsFromIndex);
- if (ttsFromRange && currentSection) {
- const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
- viewSettings.ttsLocation = ttsLocation;
- setViewSettings(bookKey, viewSettings);
- if (isCfiInLocation(ttsLocation, location)) {
- setShowBackToCurrentTTSLocation(false);
- }
- }
-
- const primaryLang = bookData.book.primaryLanguage;
-
- if (ttsControllerRef.current) {
- ttsControllerRef.current.stop();
- ttsControllerRef.current = null;
- }
+ // Guard against concurrent starts (e.g. rapid double-clicks on the TTS
+ // icon). Without this, both invocations race past the `await`s below and
+ // end up creating two TTSController instances that speak simultaneously.
+ if (isStartingTTSRef.current) return;
+ isStartingTTSRef.current = true;
try {
- if (appService?.isIOSApp) {
- await invokeUseBackgroundAudio({ enabled: true });
+ const view = getView(bookKey);
+ const progress = getProgress(bookKey);
+ const viewSettings = getViewSettings(bookKey);
+ const bookData = getBookData(bookKey);
+ const { location } = progress || {};
+ if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
+ const ttsSpeakRange = range as Range | null;
+ let ttsFromRange = ttsSpeakRange;
+ let ttsFromIndex = typeof index === 'number' ? index : null;
+ if (!ttsFromRange && viewSettings.ttsLocation) {
+ const ttsCfi = viewSettings.ttsLocation;
+ if (isCfiInLocation(ttsCfi, location)) {
+ const { index, anchor } = view.resolveCFI(ttsCfi);
+ const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
+ if (doc) {
+ ttsFromRange = anchor(doc);
+ ttsFromIndex = index;
+ }
+ }
}
- if (appService?.isMobile) {
- unblockAudio();
+
+ if (!ttsFromIndex) {
+ ttsFromIndex = progress.index;
}
- await initMediaSession();
- setTtsClientsInitialized(false);
- setShowIndicator(true);
- const ttsController = new TTSController(
- appService,
- view,
- !!user?.id,
- preprocessSSMLForTTS,
- handleSectionChange,
- );
- ttsControllerRef.current = ttsController;
- setTtsController(ttsController);
-
- await ttsController.init();
- await ttsController.initViewTTS(ttsFromIndex);
- ttsController.updateHighlightOptions(
- getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
- );
- const ssml =
- oneTime && ttsSpeakRange
- ? genSSMLRaw(ttsSpeakRange.toString().trim())
- : ttsFromRange
- ? view.tts?.from(ttsFromRange)
- : view.tts?.start();
- if (ssml) {
- const lang = parseSSMLLang(ssml, primaryLang) || 'en';
- setIsPlaying(true);
- setTtsLang(lang);
-
- ttsController.setLang(lang);
- ttsController.setRate(viewSettings.ttsRate);
- ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
- ttsController.setTargetLang(getTTSTargetLang() || '');
+ if (!ttsFromRange && !bookData.isFixedLayout) {
+ ttsFromRange = progress.range;
}
- setTtsClientsInitialized(true);
- setTTSEnabled(bookKey, true);
- } catch (error) {
- eventDispatcher.dispatch('toast', {
- message: _('TTS not supported for this document'),
- type: 'error',
- });
- console.error(error);
+
+ const currentSection = view.renderer.getContents().find((x) => x.index === ttsFromIndex);
+ if (ttsFromRange && currentSection) {
+ const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
+ viewSettings.ttsLocation = ttsLocation;
+ setViewSettings(bookKey, viewSettings);
+ if (isCfiInLocation(ttsLocation, location)) {
+ setShowBackToCurrentTTSLocation(false);
+ }
+ }
+
+ const primaryLang = bookData.book.primaryLanguage;
+
+ if (ttsControllerRef.current) {
+ ttsControllerRef.current.stop();
+ ttsControllerRef.current = null;
+ }
+
+ try {
+ if (appService?.isIOSApp) {
+ await invokeUseBackgroundAudio({ enabled: true });
+ }
+ if (appService?.isMobile) {
+ unblockAudio();
+ }
+ await initMediaSession();
+ setTtsClientsInitialized(false);
+
+ setShowIndicator(true);
+ const ttsController = new TTSController(
+ appService,
+ view,
+ !!user?.id,
+ preprocessSSMLForTTS,
+ handleSectionChange,
+ );
+ ttsControllerRef.current = ttsController;
+ setTtsController(ttsController);
+
+ await ttsController.init();
+ await ttsController.initViewTTS(ttsFromIndex);
+ ttsController.updateHighlightOptions(
+ getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
+ );
+ const ssml =
+ oneTime && ttsSpeakRange
+ ? genSSMLRaw(ttsSpeakRange.toString().trim())
+ : ttsFromRange
+ ? view.tts?.from(ttsFromRange)
+ : view.tts?.start();
+ if (ssml) {
+ const lang = parseSSMLLang(ssml, primaryLang) || 'en';
+ setIsPlaying(true);
+ setTtsLang(lang);
+
+ ttsController.setLang(lang);
+ ttsController.setRate(viewSettings.ttsRate);
+ ttsController.speak(ssml, oneTime, () => handleStop(bookKey));
+ ttsController.setTargetLang(getTTSTargetLang() || '');
+ }
+ setTtsClientsInitialized(true);
+ setTTSEnabled(bookKey, true);
+ } catch (error) {
+ eventDispatcher.dispatch('toast', {
+ message: _('TTS not supported for this document'),
+ type: 'error',
+ });
+ console.error(error);
+ }
+ } finally {
+ isStartingTTSRef.current = false;
}
};