import clsx from 'clsx'; import React, { useState, useRef, useEffect, useCallback } from 'react'; import { useEnv } from '@/context/EnvContext'; 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 { getPopupPosition, Position } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; import { parseSSMLLang } from '@/utils/ssml'; import { getOSPlatform } from '@/utils/misc'; import { throttle } from '@/utils/throttle'; import { invokeUseBackgroundAudio } from '@/utils/bridge'; import Popup from '@/components/Popup'; import TTSPanel from './TTSPanel'; import TTSIcon from './TTSIcon'; const POPUP_WIDTH = 282; const POPUP_HEIGHT = 160; const POPUP_PADDING = 10; const TTSControl = () => { const _ = useTranslation(); const { appService } = useEnv(); const { getBookData } = useBookDataStore(); const { getView, getViewSettings } = useReaderStore(); const [bookKey, setBookKey] = useState(''); const [ttsLang, setTtsLang] = useState('en'); const [isPlaying, setIsPlaying] = useState(false); const [isPaused, setIsPaused] = useState(false); const [showIndicator, setShowIndicator] = useState(false); const [showPanel, setShowPanel] = useState(false); const [panelPosition, setPanelPosition] = useState(); const [trianglePosition, setTrianglePosition] = useState(); const [timeoutOption, setTimeoutOption] = useState(0); const [timeoutTimestamp, setTimeoutTimestamp] = useState(0); const [timeoutFunc, setTimeoutFunc] = useState | null>(null); 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(null); const ttsControllerRef = useRef(null); const unblockerAudioRef = useRef(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.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); } }; useEffect(() => { return () => { if (ttsControllerRef.current) { ttsControllerRef.current.kill(); ttsControllerRef.current = null; } }; }, []); useEffect(() => { eventDispatcher.on('tts-speak', handleTTSSpeak); eventDispatcher.on('tts-stop', handleTTSStop); eventDispatcher.onSync('tts-is-speaking', handleQueryIsSpeaking); return () => { eventDispatcher.off('tts-speak', handleTTSSpeak); eventDispatcher.off('tts-stop', handleTTSStop); eventDispatcher.offSync('tts-is-speaking', handleQueryIsSpeaking); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleTTSSpeak = async (event: CustomEvent) => { const { bookKey, range } = event.detail; const view = getView(bookKey); const viewSettings = getViewSettings(bookKey); const bookData = getBookData(bookKey); if (!view || !viewSettings || !bookData) return; if (bookData.book?.format === 'PDF') { eventDispatcher.dispatch('toast', { message: _('TTS not supported for PDF'), type: 'warning', }); return; } setBookKey(bookKey); if (ttsControllerRef.current) { ttsControllerRef.current.stop(); ttsControllerRef.current = null; } setShowIndicator(true); try { if (appService?.isIOSApp) { await invokeUseBackgroundAudio({ enabled: true }); } if (getOSPlatform() === 'ios' || appService?.isIOSApp) { unblockAudio(); } const ttsController = new TTSController(view); await ttsController.init(); await ttsController.initViewTTS(); const ssml = view.tts?.from(range); if (ssml) { const lang = parseSSMLLang(ssml) || 'en'; setTtsLang(lang); setIsPlaying(true); ttsController.setLang(lang); ttsController.setRate(viewSettings.ttsRate); ttsController.setVoice(viewSettings.ttsVoice); ttsController.speak(ssml); ttsControllerRef.current = ttsController; } } catch (error) { eventDispatcher.dispatch('toast', { message: _('TTS not supported in this device'), type: 'error', }); console.error(error); } }; const handleTTSStop = async () => { handleStop(); }; const handleQueryIsSpeaking = () => { return !!ttsControllerRef.current; }; const handleTogglePlay = 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(); } } }; const handleBackward = async () => { const ttsController = ttsControllerRef.current; if (ttsController) { await ttsController.backward(); } }; const handleForward = async () => { const ttsController = ttsControllerRef.current; if (ttsController) { await ttsController.forward(); } }; const handleStop = async () => { const ttsController = ttsControllerRef.current; if (ttsController) { await ttsController.stop(); ttsControllerRef.current = null; getView(bookKey)?.deselect(); setIsPlaying(false); setShowPanel(false); setShowIndicator(false); } if (appService?.isIOSApp) { await invokeUseBackgroundAudio({ enabled: false }); } if (getOSPlatform() === 'ios' || appService?.isIOSApp) { releaseUnblockAudio(); } }; // 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) => { const ttsController = ttsControllerRef.current; if (ttsController) { if (ttsController.state === 'playing') { await ttsController.stop(); await ttsController.setVoice(voice); await ttsController.start(); } else { await ttsController.setVoice(voice); } } }, 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 = (value: number) => { setTimeoutOption(value); if (timeoutFunc) { clearTimeout(timeoutFunc); } if (value > 0) { setTimeoutFunc( setTimeout(() => { handleStop(); }, value * 1000), ); setTimeoutTimestamp(Date.now() + value * 1000); } else { setTimeoutTimestamp(0); } }; const updatePanelPosition = () => { if (iconRef.current) { const rect = iconRef.current.getBoundingClientRect(); const windowRect = document.documentElement.getBoundingClientRect(); const trianglePos = { dir: 'up', point: { x: rect.left + rect.width / 2, y: rect.top - 12 }, } as Position; const popupPos = getPopupPosition( trianglePos, windowRect, popupWidth, popupHeight, popupPadding, ); setPanelPosition(popupPos); setTrianglePosition(trianglePos); } }; const togglePopup = () => { updatePanelPosition(); setShowPanel((prev) => !prev); }; const handleDismissPopup = () => { setShowPanel(false); }; return (
{showPanel && (
)} {showIndicator && (
)} {showPanel && panelPosition && trianglePosition && ( )}
); }; export default TTSControl;