tts: show speaking sentence and chapter info in tts media session (#1289)

This commit is contained in:
Huang Xin
2025-05-31 15:21:17 +08:00
committed by GitHub
parent e4d217f3aa
commit 77fa57438e
14 changed files with 148 additions and 34 deletions
@@ -41,8 +41,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
@@ -53,6 +53,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
@@ -147,6 +148,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
@@ -312,7 +314,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
<Button
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
@@ -351,7 +356,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
tooltip={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
@@ -91,8 +91,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
view?.deselect();
};
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
useTextSelector(bookKey, setSelection, handleDismissPopup);
const {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -105,7 +112,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
@@ -5,7 +5,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA } from '@/services/tts';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
@@ -24,7 +24,7 @@ const TTSControl = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { getView, getProgress, getViewSettings, setTTSEnabled } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
@@ -45,8 +45,10 @@ const TTSControl = () => {
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
@@ -95,6 +97,33 @@ const TTSControl = () => {
// 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 handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
};
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
return () => {
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
@@ -116,6 +145,8 @@ const TTSControl = () => {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setTTSEnabled(bookKey, true);
setShowIndicator(true);
try {
@@ -125,6 +156,7 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
unblockAudio();
}
setTtsClientsInitialized(false);
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
@@ -142,7 +174,9 @@ const TTSControl = () => {
ttsController.setRate(viewSettings.ttsRate);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
}
setTtsClientsInitialized(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
@@ -152,9 +186,10 @@ const TTSControl = () => {
}
};
const handleTTSStop = async () => {
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (ttsControllerRef.current) {
handleStop();
handleStop(bookKey);
}
};
@@ -197,11 +232,12 @@ const TTSControl = () => {
}
};
const handleStop = async () => {
const handleStop = async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
@@ -213,6 +249,7 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
// rate range: 0.5 - 3, 1.0 is normal speed
@@ -266,7 +303,7 @@ const TTSControl = () => {
return '';
};
const handleSelectTimeout = (value: number) => {
const handleSelectTimeout = (bookKey: string, value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
@@ -274,7 +311,7 @@ const TTSControl = () => {
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop();
handleStop(bookKey);
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
@@ -356,10 +393,10 @@ const TTSControl = () => {
: 'bottom-[70px] sm:bottom-14',
)}
>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
<Popup
width={popupWidth}
height={popupHeight}
@@ -1,20 +1,28 @@
import clsx from 'clsx';
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
ttsInited: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
<div
className={clsx(
'relative h-full w-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
)}
onClick={onClick}
>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
}}
/>
</div>
@@ -23,7 +23,7 @@ type TTSPanelProps = {
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
onSelectTimeout: (bookKey: string, value: number) => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -155,7 +155,7 @@ const TTSPanel = ({
const updateTimeout = (timeout: number) => {
const now = Date.now();
if (timeout > 0 && timeout < now) {
onSelectTimeout(0);
onSelectTimeout(bookKey, 0);
setTimeoutCountdown('');
} else if (timeout > 0) {
setTimeoutCountdown(getCountdownTime(timeout));
@@ -256,7 +256,10 @@ const TTSPanel = ({
)}
>
{timeoutOptions.map((option, index) => (
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
<li
key={`${index}-${option.value}`}
onClick={() => onSelectTimeout(bookKey, option.value)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}