forked from akai/readest
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:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user