fix(tts): prevent double playback on rapid TTS icon clicks (#3764)

Guard handleTTSSpeak with a single-flight ref so a second tts-speak
event that arrives while the first is still inside its initial awaits
(initMediaSession / backgroundAudio / TTSController.init) is ignored
instead of racing ahead to construct a second TTSController. Without
this, rapid clicks produced two concurrent speakers talking over each
other because viewState.ttsEnabled is only set at the end of the first
invocation, so the footer bar would dispatch tts-speak twice.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-06 01:11:28 +08:00
committed by GitHub
parent b9a2b10fac
commit b679817fce
2 changed files with 344 additions and 91 deletions
@@ -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('<speak>hello</speak>'),
start: vi.fn().mockReturnValue('<speak>hello</speak>'),
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<string, unknown>) {
Object.assign(this, {
init: vi.fn().mockImplementation(
() =>
new Promise<void>((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) => `<speak>${s}</speak>`),
parseSSMLLang: vi.fn(() => 'en'),
}));
vi.mock('@/utils/throttle', () => ({
throttle: <T extends (...args: unknown[]) => 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(<Harness />);
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]);
});
});
});
@@ -50,6 +50,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const sectionChangingTimestampRef = useRef(0);
const previousSectionLabelRef = useRef<string | undefined>(undefined);
const ttsControllerRef = useRef<TTSController | null>(null);
const isStartingTTSRef = useRef(false);
const [ttsController, setTtsController] = useState<TTSController | null>(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;
}
};