fix(tts): navigate to follow the current TTS location for the next section, closes #3198 (#3276)

This commit is contained in:
Huang Xin
2026-02-12 23:13:41 +08:00
committed by GitHub
parent 1e81ab5205
commit 548d50a882
6 changed files with 872 additions and 722 deletions
@@ -1,28 +1,14 @@
import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA, TTSMark, TTSHighlightOptions } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { useTTSControl } from '@/app/reader/hooks/useTTSControl';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import { fetchImageAsBase64 } from '@/utils/image';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { isCfiInLocation } from '@/utils/cfi';
import { getLocale } from '@/utils/misc';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -40,688 +26,51 @@ interface TTSControlProps {
const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { safeAreaInsets, isDarkMode } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const { getMergedRules } = useProofreadStore();
const { safeAreaInsets } = useThemeStore();
const { hoveredBookKey, getViewSettings } = useReaderStore();
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showPanel, setShowPanel] = useState(false);
const [showTTSBar, setShowTTSBar] = useState(() => !!viewSettings?.showTTSBar);
const [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
const [timeoutOption, setTimeoutOption] = useState(0);
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const iconRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true);
const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false);
const [shouldMountBackButton, setShouldMountBackButton] = useState(false);
const [isBackButtonVisible, setIsBackButtonVisible] = useState(false);
const backButtonTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const followingTTSLocationRef = useRef(true);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
const ttsOnRef = useRef(false);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
if (unblockerAudioRef.current) return;
unblockerAudioRef.current = document.createElement('audio');
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
}
});
unblockerAudioRef.current.preload = 'auto';
unblockerAudioRef.current.loop = true;
unblockerAudioRef.current.src = SILENCE_DATA;
unblockerAudioRef.current.play();
};
const releaseUnblockAudio = () => {
if (!unblockerAudioRef.current) return;
try {
unblockerAudioRef.current.pause();
unblockerAudioRef.current.currentTime = 0;
unblockerAudioRef.current.removeAttribute('src');
unblockerAudioRef.current.src = '';
unblockerAudioRef.current.load();
unblockerAudioRef.current = null;
console.log('Unblock audio released');
} catch (err) {
console.warn('Error releasing unblock audio:', err);
}
};
const initMediaSession = async () => {
const mediaSession = getMediaSession();
if (!mediaSession) return;
mediaSessionRef.current = mediaSession;
if (mediaSession instanceof TauriMediaSession) {
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const { sectionLabel } = progress || {};
let artworkImage = '/icon.png';
try {
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
} catch {
artworkImage = await fetchImageAsBase64('/icon.png');
}
await mediaSession.setActive({
active: true,
keepAppInForeground: settings.alwaysInForeground,
notificationTitle: _('Read Aloud'),
notificationText: _('Ready to read aloud'),
foregroundServiceTitle: _('Read Aloud'),
foregroundServiceText: _('Ready to read aloud'),
});
mediaSession.updateMetadata({
title: title,
artist: sectionLabel || title,
album: author,
artwork: artworkImage,
});
}
};
const deinitMediaSession = async () => {
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
await mediaSessionRef.current.setActive({
active: false,
keepAppInForeground: settings.alwaysInForeground,
});
}
mediaSessionRef.current = null;
};
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
ttsControllerRef.current.shutdown();
ttsControllerRef.current = null;
}
};
}, []);
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!ttsController || !bookKey) return;
const bookData = getBookData(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const handleNeedAuth = () => {
eventDispatcher.dispatch('toast', {
message: _('Please log in to use advanced TTS features'),
type: 'error',
timeout: 5000,
});
};
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
mediaSession.updateMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: '',
});
} else {
mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
}
};
const handleHighlightMark = (e: Event) => {
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!cfi || !view || !viewSettings) return;
viewSettings.ttsLocation = cfi;
setViewSettings(bookKey, viewSettings);
if (!followingTTSLocationRef.current) return;
const docs = view.renderer.getContents();
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
return;
}
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
doc: Document;
index?: number;
};
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
if (viewSectionIndex !== ttsSectionIndex) {
return;
}
const range = anchor(doc);
if (!view.renderer.scrolled) {
view.renderer.scrollToAnchor(range);
} else {
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = view.renderer;
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
const showHeader = viewSettings.showHeader;
const showFooter = viewSettings.showFooter;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
const scrollingOverlap = viewSettings.scrollingOverlap;
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
if (endInNextView || startInPrevView) {
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
view.renderer.scrollToAnchor(scrollTo / viewSize);
}
}
};
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
return () => {
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
useEffect(() => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const ttsLocation = viewSettings?.ttsLocation;
const { location } = progress || {};
if (!location || !ttsLocation) return;
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
if (ttsInCurrentLocation) {
setShowBackToCurrentTTSLocation(false);
const { doc, index: viewSectionIndex } = view?.renderer.getContents()[0] as {
doc: Document;
index?: number;
};
const { anchor, index: ttsSectionIndex } = view?.resolveCFI(ttsLocation) || {};
if (viewSectionIndex !== ttsSectionIndex) {
return;
}
const range = anchor?.(doc);
if (range) {
view?.tts?.highlight(range);
}
} else {
setShowBackToCurrentTTSLocation(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
const handleBackToCurrentTTSLocation = () => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const ttsLocation = viewSettings?.ttsLocation;
if (!view || !ttsLocation) return;
const resolved = view.resolveNavigation(ttsLocation);
view.renderer.goTo?.(resolved);
};
const getTTSTargetLang = useCallback((): string | null => {
const ttsReadAloudText = viewSettings?.ttsReadAloudText;
if (viewSettings?.translationEnabled && ttsReadAloudText === 'translated') {
return viewSettings?.translateTargetLang || getLocale();
} else if (viewSettings?.translationEnabled && ttsReadAloudText === 'source') {
const bookData = getBookData(bookKey);
return bookData?.book?.primaryLanguage || '';
}
return null;
}, [
const tts = useTTSControl({
bookKey,
getBookData,
viewSettings?.translationEnabled,
viewSettings?.ttsReadAloudText,
viewSettings?.translateTargetLang,
]);
onRequestHidePanel: () => setShowPanel(false),
});
useEffect(() => {
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
}, [getTTSTargetLang]);
const transformCtx: TransformContext = useMemo(
() => ({
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: [],
reversePunctuationTransform: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const preprocessSSMLForTTS = useCallback(
async (ssml: string) => {
const rules = getMergedRules(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const ttsOnlyRules = rules.filter(
(rule) =>
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
);
if (ttsOnlyRules.length === 0) return ssml;
transformCtx['content'] = ssml;
transformCtx['viewSettings'] = viewSettings;
ssml = await proofreadTransformer.transform(transformCtx, {
docType: 'text/xml',
onlyForTTS: true,
});
return ssml;
},
[bookKey, getMergedRules, getViewSettings, transformCtx],
);
const getTTSHighlightOptions = useCallback(
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
return {
...ttsHighlightOptions,
color,
};
},
[isDarkMode],
);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(
getTTSHighlightOptions(ttsHighlightOptions, viewSettings.isEink),
);
}
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
if (ttsOnRef.current) return;
ttsOnRef.current = true;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
type: 'warning',
});
if (tts.showBackToCurrentTTSLocation) {
setShouldMountBackButton(true);
const fadeInTimeout = setTimeout(() => {
setIsBackButtonVisible(true);
}, 10);
return () => clearTimeout(fadeInTimeout);
} else {
setIsBackButtonVisible(false);
if (backButtonTimeoutRef.current) {
clearTimeout(backButtonTimeoutRef.current);
}
backButtonTimeoutRef.current = setTimeout(() => {
setShouldMountBackButton(false);
}, 300);
return;
}
const ttsSpeakRange = range as Range | null;
let ttsFromRange = ttsSpeakRange;
if (!ttsFromRange && viewSettings.ttsLocation) {
const { location } = progress;
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);
}
}
}
if (!ttsFromRange) {
ttsFromRange = progress.range;
}
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);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml =
oneTime && ttsSpeakRange
? genSSMLRaw(ttsSpeakRange.toString().trim())
: view.tts?.from(ttsFromRange);
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);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey: ttsBookKey } = event.detail;
if (ttsControllerRef.current && bookKey === ttsBookKey) {
handleStop(bookKey);
}
};
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
} else if (isPaused) {
setIsPlaying(true);
setIsPaused(false);
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
await ttsController.resume();
} else {
await ttsController.start();
}
}
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
await mediaSession.updatePlaybackState({ playing: !isPlaying });
} else {
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
}
}
}, [isPlaying, isPaused]);
const handleBackward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward(byMark);
}
}, []);
const handleForward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward(byMark);
}
}, []);
const handlePause = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
}
}, []);
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
setShowBackToCurrentTTSLocation(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
ttsOnRef.current = false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetRate = useCallback(
throttle(async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setRate(rate);
await ttsController.start();
} else {
await ttsController.setRate(rate);
}
}
}, 3000),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string, lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice, lang);
await ttsController.start();
} else {
await ttsController.setVoice(voice, lang);
}
}
}, 3000),
[],
);
const handleGetVoices = async (lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const handleSelectTimeout = (bookKey: string, value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
}
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop(bookKey);
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
} else {
setTimeoutTimestamp(0);
}
};
const handleToggleTTSBar = () => {
const viewSettings = getViewSettings(bookKey)!;
viewSettings.showTTSBar = !viewSettings.showTTSBar;
setShowTTSBar(viewSettings.showTTSBar);
if (viewSettings.showTTSBar) {
setShowPanel(false);
}
setViewSettings(bookKey, viewSettings);
};
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const parentRect =
iconRef.current.parentElement?.getBoundingClientRect() ||
document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
parentRect,
popupWidth,
popupHeight,
popupPadding,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
if (!showPanel && ttsControllerRef.current) {
const speakingLang = ttsControllerRef.current.getSpeakingLang() || ttsLang;
setTtsLang(speakingLang);
}
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
useEffect(() => {
const { current: mediaSession } = mediaSessionRef;
if (mediaSession) {
mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
mediaSession.setActionHandler('stop', () => {
handlePause();
});
mediaSession.setActionHandler('seekforward', () => {
handleForward(true);
});
mediaSession.setActionHandler('seekbackward', () => {
handleBackward(true);
});
mediaSession.setActionHandler('nexttrack', () => {
handleForward();
});
mediaSession.setActionHandler('previoustrack', () => {
handleBackward();
});
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward]);
}, [tts.showBackToCurrentTTSLocation]);
useEffect(() => {
if (!iconRef.current || !showPanel) return;
@@ -767,25 +116,47 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}, [hoveredBookKey]);
useEffect(() => {
if (showBackToCurrentTTSLocation) {
followingTTSLocationRef.current = false;
setShouldMountBackButton(true);
const fadeInTimeout = setTimeout(() => {
setIsBackButtonVisible(true);
}, 10);
return () => clearTimeout(fadeInTimeout);
} else {
followingTTSLocationRef.current = true;
setIsBackButtonVisible(false);
if (backButtonTimeoutRef.current) {
clearTimeout(backButtonTimeoutRef.current);
}
backButtonTimeoutRef.current = setTimeout(() => {
setShouldMountBackButton(false);
}, 300);
return;
if (tts.showTTSBar) {
setShowPanel(false);
}
}, [showBackToCurrentTTSLocation]);
}, [tts.showTTSBar]);
const updatePanelPosition = () => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
const parentRect =
iconRef.current.parentElement?.getBoundingClientRect() ||
document.documentElement.getBoundingClientRect();
const trianglePos = {
dir: 'up',
point: { x: rect.left + rect.width / 2 - parentRect.left, y: rect.top - 12 },
} as Position;
const popupPos = getPopupPosition(
trianglePos,
parentRect,
popupWidth,
popupHeight,
popupPadding,
);
setPanelPosition(popupPos);
setTrianglePosition(trianglePos);
}
};
const togglePopup = () => {
updatePanelPosition();
if (!showPanel && tts.isTTSActive) {
tts.refreshTtsLang();
}
setShowPanel((prev) => !prev);
};
const handleDismissPopup = () => {
setShowPanel(false);
};
return (
<>
@@ -802,9 +173,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}}
>
<button
onClick={handleBackToCurrentTTSLocation}
onClick={tts.handleBackToCurrentTTSLocation}
className={clsx(
'bg-base-300 rounded-full px-4 py-2 font-sans text-sm shadow-lg',
'not-eink:bg-base-300 eink-bordered rounded-full px-4 py-2 font-sans text-sm shadow-lg',
safeAreaInsets?.top ? 'h-11' : 'h-9',
)}
>
@@ -813,7 +184,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
</div>
)}
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
{(showPanel || (showIndicator && showIndicatorWithinTimeout)) && (
{(showPanel || (tts.showIndicator && showIndicatorWithinTimeout)) && (
<div
ref={iconRef}
className={clsx(
@@ -828,10 +199,14 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
: undefined,
}}
>
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
<TTSIcon
isPlaying={tts.isPlaying}
ttsInited={tts.ttsClientsInited}
onClick={togglePopup}
/>
</div>
)}
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
{showPanel && panelPosition && trianglePosition && tts.ttsClientsInited && (
<Popup
width={popupWidth}
height={popupHeight}
@@ -842,29 +217,29 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
>
<TTSPanel
bookKey={bookKey}
ttsLang={ttsLang}
isPlaying={isPlaying}
timeoutOption={timeoutOption}
timeoutTimestamp={timeoutTimestamp}
onTogglePlay={handleTogglePlay}
onBackward={handleBackward}
onForward={handleForward}
onSetRate={handleSetRate}
onGetVoices={handleGetVoices}
onSetVoice={handleSetVoice}
onGetVoiceId={handleGetVoiceId}
onSelectTimeout={handleSelectTimeout}
onToogleTTSBar={handleToggleTTSBar}
ttsLang={tts.ttsLang}
isPlaying={tts.isPlaying}
timeoutOption={tts.timeoutOption}
timeoutTimestamp={tts.timeoutTimestamp}
onTogglePlay={tts.handleTogglePlay}
onBackward={tts.handleBackward}
onForward={tts.handleForward}
onSetRate={tts.handleSetRate}
onGetVoices={tts.handleGetVoices}
onSetVoice={tts.handleSetVoice}
onGetVoiceId={tts.handleGetVoiceId}
onSelectTimeout={tts.handleSelectTimeout}
onToogleTTSBar={tts.handleToggleTTSBar}
/>
</Popup>
)}
{showIndicator && showTTSBar && ttsClientsInited && (
{tts.showIndicator && tts.showTTSBar && tts.ttsClientsInited && (
<TTSBar
bookKey={bookKey}
isPlaying={isPlaying}
onBackward={handleBackward}
onTogglePlay={handleTogglePlay}
onForward={handleForward}
isPlaying={tts.isPlaying}
onBackward={tts.handleBackward}
onTogglePlay={tts.handleTogglePlay}
onForward={tts.handleForward}
gridInsets={gridInsets}
/>
)}
@@ -0,0 +1,659 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useAuth } from '@/context/AuthContext';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useProofreadStore } from '@/store/proofreadStore';
import { TransformContext } from '@/services/transformers/types';
import { proofreadTransformer } from '@/services/transformers/proofread';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController, TTSMark, TTSHighlightOptions, TTSVoicesGroup } from '@/services/tts';
import { TauriMediaSession } from '@/libs/mediaSession';
import { eventDispatcher } from '@/utils/event';
import { genSSMLRaw, parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { isCfiInLocation } from '@/utils/cfi';
import { getLocale } from '@/utils/misc';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { useTTSMediaSession } from './useTTSMediaSession';
interface UseTTSControlProps {
bookKey: string;
onRequestHidePanel?: () => void;
}
export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProps) => {
const _ = useTranslation();
const { appService } = useEnv();
const { user } = useAuth();
const { isDarkMode } = useThemeStore();
const { getBookData } = useBookDataStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const { getMergedRules } = useProofreadStore();
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [showIndicator, setShowIndicator] = useState(false);
const [showTTSBar, setShowTTSBar] = useState(() => !!getViewSettings(bookKey)?.showTTSBar);
const [showBackToCurrentTTSLocation, setShowBackToCurrentTTSLocation] = useState(false);
const [timeoutOption, setTimeoutOption] = useState(0);
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const followingTTSLocationRef = useRef(true);
const sectionChangingTimestampRef = useRef(0);
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
const ttsOnRef = useRef(false);
const {
mediaSessionRef,
unblockAudio,
releaseUnblockAudio,
initMediaSession,
deinitMediaSession,
} = useTTSMediaSession({ bookKey });
useEffect(() => {
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
if (ttsControllerRef.current) {
ttsControllerRef.current.shutdown();
ttsControllerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Controller event listeners (re-registered when ttsController changes)
useEffect(() => {
if (!ttsController || !bookKey) return;
const bookData = getBookData(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const handleNeedAuth = () => {
eventDispatcher.dispatch('toast', {
message: _('Please log in to use advanced TTS features'),
type: 'error',
timeout: 5000,
});
};
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
mediaSession.updateMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: '',
});
} else {
mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
}
};
const handleHighlightMark = (e: Event) => {
const { cfi } = (e as CustomEvent<{ cfi: string }>).detail;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const { location } = progress || {};
if (!cfi || !view || !location || !viewSettings) return;
viewSettings.ttsLocation = cfi;
setViewSettings(bookKey, viewSettings);
if (!followingTTSLocationRef.current) return;
const docs = view.renderer.getContents();
if (docs.some(({ doc }) => (doc.getSelection()?.toString().length ?? 0) > 0)) {
return;
}
const { doc, index: viewSectionIndex } = view.renderer.getContents()[0] as {
doc: Document;
index?: number;
};
const { anchor, index: ttsSectionIndex } = view.resolveCFI(cfi);
if (viewSectionIndex !== ttsSectionIndex) {
return;
}
const range = anchor(doc);
if (!view.renderer.scrolled) {
view.renderer.scrollToAnchor(range);
} else {
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = view.renderer;
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
const showHeader = viewSettings.showHeader;
const showFooter = viewSettings.showFooter;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
const scrollingOverlap = viewSettings.scrollingOverlap;
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
if (endInNextView || startInPrevView) {
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
view.renderer.scrollToAnchor(scrollTo / viewSize);
}
}
};
ttsController.addEventListener('tts-need-auth', handleNeedAuth);
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
return () => {
ttsController.removeEventListener('tts-need-auth', handleNeedAuth);
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
// Location tracking — re-highlight when progress changes
const progress = getProgress(bookKey);
useEffect(() => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const ttsLocation = viewSettings?.ttsLocation;
const { location } = progress || {};
if (!location || !ttsLocation) return;
const ttsInCurrentLocation = isCfiInLocation(ttsLocation, location);
if (ttsInCurrentLocation) {
setShowBackToCurrentTTSLocation(false);
const range = view?.tts?.getLastRange() as Range | null;
if (range) {
view?.tts?.highlight(range);
}
} else {
const msSinceSectionChange = Date.now() - sectionChangingTimestampRef.current;
if (msSinceSectionChange < 2000) return;
setShowBackToCurrentTTSLocation(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
// Location tracking — keep followingTTSLocationRef in sync with showBackToCurrentTTSLocation
useEffect(() => {
if (showBackToCurrentTTSLocation) {
followingTTSLocationRef.current = false;
} else {
followingTTSLocationRef.current = true;
}
}, [showBackToCurrentTTSLocation]);
// Location tracking — handleBackToCurrentTTSLocation
const handleBackToCurrentTTSLocation = () => {
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
const ttsLocation = viewSettings?.ttsLocation;
if (!view || !ttsLocation) return;
const resolved = view.resolveNavigation(ttsLocation);
view.renderer.goTo?.(resolved);
};
const viewSettings = getViewSettings(bookKey);
const getTTSTargetLang = useCallback((): string | null => {
const vs = getViewSettings(bookKey);
const ttsReadAloudText = vs?.ttsReadAloudText;
if (vs?.translationEnabled && ttsReadAloudText === 'translated') {
return vs?.translateTargetLang || getLocale();
} else if (vs?.translationEnabled && ttsReadAloudText === 'source') {
const bookData = getBookData(bookKey);
return bookData?.book?.primaryLanguage || '';
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
bookKey,
getBookData,
getViewSettings,
viewSettings?.translationEnabled,
viewSettings?.ttsReadAloudText,
viewSettings?.translateTargetLang,
]);
useEffect(() => {
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
}, [getTTSTargetLang]);
// SSML preprocessing
const transformCtx: TransformContext = useMemo(
() => ({
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: [],
reversePunctuationTransform: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const preprocessSSMLForTTS = useCallback(
async (ssml: string) => {
const rules = getMergedRules(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const ttsOnlyRules = rules.filter(
(rule) =>
rule.enabled && rule.onlyForTTS && (rule.scope === 'book' || rule.scope === 'library'),
);
if (ttsOnlyRules.length === 0) return ssml;
transformCtx['content'] = ssml;
transformCtx['viewSettings'] = viewSettings;
ssml = await proofreadTransformer.transform(transformCtx, {
docType: 'text/xml',
onlyForTTS: true,
});
return ssml;
},
[bookKey, getMergedRules, getViewSettings, transformCtx],
);
// Section change callback
const handleSectionChange = useCallback(
async (sectionIndex: number) => {
if (!followingTTSLocationRef.current) return;
const view = getView(bookKey);
const sections = view?.book.sections;
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) return;
sectionChangingTimestampRef.current = Date.now();
const resolved = view.resolveNavigation(sectionIndex);
view.renderer.goTo?.(resolved);
},
[bookKey, getView],
);
// TTS highlight options
const getTTSHighlightOptions = useCallback(
(ttsHighlightOptions: TTSHighlightOptions, isEink: boolean) => {
const einkBgColor = isDarkMode ? '#000000' : '#ffffff';
const color = isEink ? einkBgColor : ttsHighlightOptions.color;
return {
...ttsHighlightOptions,
color,
};
},
[isDarkMode],
);
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(
getTTSHighlightOptions(ttsHighlightOptions, viewSettings!.isEink),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewSettings?.ttsHighlightOptions, viewSettings?.isEink, getTTSHighlightOptions]);
// handleStop (defined before handleTTSSpeak/handleTTSStop which reference it)
const handleStop = useCallback(
async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.shutdown();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
onRequestHidePanel?.();
setShowIndicator(false);
setShowBackToCurrentTTSLocation(false);
}
if (appService?.isIOSApp) {
await invokeUseBackgroundAudio({ enabled: false });
}
if (appService?.isMobile) {
releaseUnblockAudio();
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
ttsOnRef.current = false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[appService],
);
// handleTTSSpeak / handleTTSStop (plain functions, registered once at mount via closure)
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
if (ttsOnRef.current) return;
ttsOnRef.current = true;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
type: 'warning',
});
return;
}
const ttsSpeakRange = range as Range | null;
let ttsFromRange = ttsSpeakRange;
if (!ttsFromRange && viewSettings.ttsLocation) {
const { location } = progress;
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);
}
}
}
if (!ttsFromRange) {
ttsFromRange = progress.range;
}
const currentSection = view.renderer.getContents()[0];
if (ttsFromRange && currentSection) {
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
viewSettings.ttsLocation = ttsLocation;
setViewSettings(bookKey, viewSettings);
}
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(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml =
oneTime && ttsSpeakRange
? genSSMLRaw(ttsSpeakRange.toString().trim())
: view.tts?.from(ttsFromRange);
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);
}
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey: ttsBookKey } = event.detail;
if (ttsControllerRef.current && bookKey === ttsBookKey) {
handleStop(bookKey);
}
};
// Playback callbacks
const handleTogglePlay = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
} else if (isPaused) {
setIsPlaying(true);
setIsPaused(false);
// start for forward/backward/setvoice-paused
// set rate don't pause the tts
if (ttsController.state === 'paused') {
await ttsController.resume();
} else {
await ttsController.start();
}
}
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
await mediaSession.updatePlaybackState({ playing: !isPlaying });
} else {
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
}
}
}, [isPlaying, isPaused, mediaSessionRef]);
const handleBackward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.backward(byMark);
}
}, []);
const handleForward = useCallback(async (byMark = false) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.forward(byMark);
}
}, []);
const handlePause = useCallback(async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
setIsPlaying(false);
setIsPaused(true);
await ttsController.pause();
}
}, []);
// Rate/voice/timeout/bar controls
// rate range: 0.5 - 3, 1.0 is normal speed
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetRate = useCallback(
throttle(async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setRate(rate);
await ttsController.start();
} else {
await ttsController.setRate(rate);
}
}
}, 3000),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleSetVoice = useCallback(
throttle(async (voice: string, lang: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
await ttsController.stop();
await ttsController.setVoice(voice, lang);
await ttsController.start();
} else {
await ttsController.setVoice(voice, lang);
}
}
}, 3000),
[],
);
const handleGetVoices = async (lang: string): Promise<TTSVoicesGroup[]> => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoices(lang);
}
return [];
};
const handleGetVoiceId = () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
return ttsController.getVoiceId();
}
return '';
};
const handleSelectTimeout = (bookKey: string, value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
}
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop(bookKey);
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
} else {
setTimeoutTimestamp(0);
}
};
const handleToggleTTSBar = () => {
const viewSettings = getViewSettings(bookKey)!;
viewSettings.showTTSBar = !viewSettings.showTTSBar;
setShowTTSBar(viewSettings.showTTSBar);
if (viewSettings.showTTSBar) {
onRequestHidePanel?.();
}
setViewSettings(bookKey, viewSettings);
};
const refreshTtsLang = useCallback(() => {
const speakingLang = ttsControllerRef.current?.getSpeakingLang();
if (speakingLang) {
setTtsLang(speakingLang);
}
}, []);
// Media session action handler effect
useEffect(() => {
const { current: mediaSession } = mediaSessionRef;
if (mediaSession) {
mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
mediaSession.setActionHandler('stop', () => {
handlePause();
});
mediaSession.setActionHandler('seekforward', () => {
handleForward(true);
});
mediaSession.setActionHandler('seekbackward', () => {
handleBackward(true);
});
mediaSession.setActionHandler('nexttrack', () => {
handleForward();
});
mediaSession.setActionHandler('previoustrack', () => {
handleBackward();
});
}
}, [handleTogglePlay, handlePause, handleForward, handleBackward, mediaSessionRef]);
return {
isPlaying,
isPaused,
ttsLang,
ttsClientsInited,
isTTSActive: ttsController !== null,
showIndicator,
showTTSBar,
showBackToCurrentTTSLocation,
timeoutOption,
timeoutTimestamp,
handleTogglePlay,
handleBackward,
handleForward,
handlePause,
handleSetRate,
handleSetVoice,
handleGetVoices,
handleGetVoiceId,
handleSelectTimeout,
handleToggleTTSBar,
handleBackToCurrentTTSLocation,
refreshTtsLang,
};
};
@@ -0,0 +1,109 @@
import { useRef } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { SILENCE_DATA } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { fetchImageAsBase64 } from '@/utils/image';
interface UseTTSMediaSessionProps {
bookKey: string;
}
export const useTTSMediaSession = ({ bookKey }: UseTTSMediaSessionProps) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { getProgress } = useReaderStore();
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
if (unblockerAudioRef.current) return;
unblockerAudioRef.current = document.createElement('audio');
unblockerAudioRef.current.setAttribute('x-webkit-airplay', 'deny');
unblockerAudioRef.current.addEventListener('play', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = null;
}
});
unblockerAudioRef.current.preload = 'auto';
unblockerAudioRef.current.loop = true;
unblockerAudioRef.current.src = SILENCE_DATA;
unblockerAudioRef.current.play();
};
const releaseUnblockAudio = () => {
if (!unblockerAudioRef.current) return;
try {
unblockerAudioRef.current.pause();
unblockerAudioRef.current.currentTime = 0;
unblockerAudioRef.current.removeAttribute('src');
unblockerAudioRef.current.src = '';
unblockerAudioRef.current.load();
unblockerAudioRef.current = null;
console.log('Unblock audio released');
} catch (err) {
console.warn('Error releasing unblock audio:', err);
}
};
const initMediaSession = async () => {
const mediaSession = getMediaSession();
if (!mediaSession) return;
mediaSessionRef.current = mediaSession;
if (mediaSession instanceof TauriMediaSession) {
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const { sectionLabel } = progress || {};
let artworkImage = '/icon.png';
try {
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
} catch {
artworkImage = await fetchImageAsBase64('/icon.png');
}
await mediaSession.setActive({
active: true,
keepAppInForeground: settings.alwaysInForeground,
notificationTitle: _('Read Aloud'),
notificationText: _('Ready to read aloud'),
foregroundServiceTitle: _('Read Aloud'),
foregroundServiceText: _('Ready to read aloud'),
});
mediaSession.updateMetadata({
title: title,
artist: sectionLabel || title,
album: author,
artwork: artworkImage,
});
}
};
const deinitMediaSession = async () => {
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
await mediaSessionRef.current.setActive({
active: false,
keepAppInForeground: settings.alwaysInForeground,
});
}
mediaSessionRef.current = null;
};
return {
mediaSessionRef,
unblockerAudioRef,
unblockAudio,
releaseUnblockAudio,
initMediaSession,
deinitMediaSession,
};
};
@@ -27,6 +27,7 @@ export class TTSController extends EventTarget {
view: FoliateView;
isAuthenticated: boolean = false;
preprocessCallback?: (ssml: string) => Promise<string>;
onSectionChange?: (sectionIndex: number) => Promise<void>;
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
#currentSpeakPromise: Promise<void> | null = null;
@@ -52,6 +53,7 @@ export class TTSController extends EventTarget {
view: FoliateView,
isAuthenticated: boolean = false,
preprocessCallback?: (ssml: string) => Promise<string>,
onSectionChange?: (sectionIndex: number) => Promise<void>,
) {
super();
this.ttsWebClient = new WebSpeechClient(this);
@@ -65,6 +67,7 @@ export class TTSController extends EventTarget {
this.view = view;
this.isAuthenticated = isAuthenticated;
this.preprocessCallback = preprocessCallback;
this.onSectionChange = onSectionChange;
}
async init() {
@@ -130,9 +133,8 @@ export class TTSController extends EventTarget {
}
async #initTTSForSection(sectionIndex: number): Promise<boolean> {
const currentSection = this.view.renderer.getContents()[0];
const sections = this.view.book.sections;
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length || !currentSection) {
if (!sections || sectionIndex < 0 || sectionIndex >= sections.length) {
return false;
}
@@ -143,6 +145,11 @@ export class TTSController extends EventTarget {
this.#ttsSectionIndex = sectionIndex;
const currentSection = this.view.renderer.getContents()[0];
if (currentSection?.index !== sectionIndex) {
await this.onSectionChange?.(sectionIndex);
}
let doc: Document;
if (currentSection?.index === sectionIndex && currentSection?.doc) {
doc = currentSection.doc;
+1 -1
View File
@@ -23,7 +23,7 @@ export interface FoliateView extends HTMLElement {
goRight: () => void;
getCFI: (index: number, range: Range) => string;
resolveCFI: (cfi: string) => { index: number; anchor: RangeAnchor };
resolveNavigation: (cfiOrHref: string) => { index: number; anchor?: RangeAnchor };
resolveNavigation: (cfiOrHrefOrIndex: string | number) => { index: number; anchor?: RangeAnchor };
addAnnotation: (
note: BookNote & { value?: string },
remove?: boolean,