tts: show speaking sentence and chapter info in tts media session (#1289)
This commit is contained in:
@@ -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' />}
|
||||
|
||||
Reference in New Issue
Block a user