This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 21293c82b8...72fda6ac40
Reference in New Issue
Block a user