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