diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 9623290d..b8d59f3b 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -8,6 +8,7 @@ import { Book } from '@/types/book'; import { AppService } from '@/types/system'; import { navigateToReader } from '@/utils/nav'; import { getBaseFilename, listFormater } from '@/utils/book'; +import { eventDispatcher } from '@/utils/event'; import { parseOpenWithFiles } from '@/helpers/cli'; import { isTauriAppPlatform, hasUpdater } from '@/services/environment'; import { checkForAppUpdates } from '@/helpers/updater'; @@ -21,11 +22,11 @@ import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useDemoBooks } from './hooks/useDemoBooks'; +import { AboutWindow } from '@/components/AboutWindow'; +import { Toast } from '@/components/Toast'; import Spinner from '@/components/Spinner'; import LibraryHeader from './components/LibraryHeader'; import Bookshelf from './components/Bookshelf'; -import { AboutWindow } from '@/components/AboutWindow'; -import Toast from '@/components/Toast'; const LibraryPage = () => { const router = useRouter(); @@ -46,16 +47,6 @@ const LibraryPage = () => { const [isSelectMode, setIsSelectMode] = useState(false); const demoBooks = useDemoBooks(); - const [toastMessage, setToastMessage] = useState(''); - const toastDismissTimeout = useRef | null>(null); - useEffect(() => { - if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current); - toastDismissTimeout.current = setTimeout(() => setToastMessage(''), 5000); - return () => { - if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current); - }; - }, [toastMessage]); - useEffect(() => { const doAppUpdates = async () => { if (hasUpdater()) { @@ -175,11 +166,12 @@ const LibraryPage = () => { const filename = typeof file === 'string' ? file : file.name; const baseFilename = getBaseFilename(filename); failedFiles.push(baseFilename); - setToastMessage( - _('Failed to import book(s): {{filenames}}', { + eventDispatcher.dispatch('toast', { + message: _('Failed to import book(s): {{filenames}}', { filenames: listFormater(false).format(failedFiles), }), - ); + type: 'error', + }); console.error('Failed to import book:', filename, error); } } @@ -283,13 +275,7 @@ const LibraryPage = () => { ))} - {toastMessage && ( - - )} + ); }; diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 6d5b54f6..ce1688c8 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -18,7 +18,6 @@ import { handleClick, handleWheel, } from '../utils/iframeEventHandlers'; -import Toast from '@/components/Toast'; const FoliateViewer: React.FC<{ bookKey: string; @@ -39,7 +38,7 @@ const FoliateViewer: React.FC<{ return () => clearTimeout(timer); }, [toastMessage]); - useProgressSync(bookKey, setToastMessage); + useProgressSync(bookKey); const progressRelocateHandler = (event: Event) => { const detail = (event as CustomEvent).detail; @@ -164,9 +163,6 @@ const FoliateViewer: React.FC<{ onClick={(event) => handleTurnPage(event)} ref={containerRef} /> - {toastMessage && ( - - )} ); }; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index e3518a1e..8a242ed9 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -5,10 +5,10 @@ import { useEffect, Suspense, useRef } from 'react'; import { useEnv } from '@/context/EnvContext'; import { useLibraryStore } from '@/store/libraryStore'; - -import ReaderContent from './ReaderContent'; -import { AboutWindow } from '@/components/AboutWindow'; import { useSettingsStore } from '@/store/settingsStore'; +import { AboutWindow } from '@/components/AboutWindow'; +import { Toast } from '@/components/Toast'; +import ReaderContent from './ReaderContent'; const Reader: React.FC<{ ids?: string }> = ({ ids }) => { const { envConfig } = useEnv(); @@ -37,6 +37,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => { + ) diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index b11a3700..fb31d781 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -23,7 +23,6 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents'; import { useNotesSync } from '../../hooks/useNotesSync'; import { getPopupPosition, getPosition, Position, TextSelection } from '@/utils/sel'; import { eventDispatcher } from '@/utils/event'; -import Toast from '@/components/Toast'; import AnnotationPopup from './AnnotationPopup'; import WiktionaryPopup from './WiktionaryPopup'; import WikipediaPopup from './WikipediaPopup'; @@ -57,7 +56,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const [annotPopupPosition, setAnnotPopupPosition] = useState(); const [dictPopupPosition, setDictPopupPosition] = useState(); const [translatorPopupPosition, setTranslatorPopupPosition] = useState(); - const [toastMessage, setToastMessage] = useState(''); const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false); const [selectedStyle, setSelectedStyle] = useState( @@ -196,11 +194,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selection, bookKey]); - useEffect(() => { - const timer = setTimeout(() => setToastMessage(''), 2000); - return () => clearTimeout(timer); - }, [toastMessage]); - useEffect(() => { if (!progress) return; const { location } = progress; @@ -225,7 +218,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const handleCopy = () => { if (!selection || !selection.text) return; - setToastMessage(_('Copied to notebook')); + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Copied to notebook'), + className: 'whitespace-nowrap', + timeout: 2000, + }); const { booknotes: annotations = [] } = config; if (selection) navigator.clipboard.writeText(selection.text); @@ -414,8 +412,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { onHighlight={handleHighlight} /> )} - - {toastMessage && } ); }; diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 84fb1189..c2725e6f 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -7,12 +7,14 @@ import { useReaderStore } from '@/store/readerStore'; import Popup from '@/components/Popup'; import TTSPanel from './TTSPanel'; import TTSIcon from './TTSIcon'; +import { useTranslation } from '@/hooks/useTranslation'; const POPUP_WIDTH = 260; const POPUP_HEIGHT = 80; const POPUP_PADDING = 10; const TTSControl = () => { + const _ = useTranslation(); const { getView } = useReaderStore(); const [isPlaying, setIsPlaying] = useState(false); const [showPanel, setShowPanel] = useState(false); @@ -44,11 +46,19 @@ const TTSControl = () => { const view = getView(bookKey); if (!view) return; - const ttsClient = new WebSpeechClient(); - const ttsController = new TTSController(ttsClient, view); - ttsControllerRef.current = ttsController; - ttsControllerRef.current.speak(ssml); - setIsPlaying(true); + try { + const ttsClient = new WebSpeechClient(); + const ttsController = new TTSController(ttsClient, view); + ttsControllerRef.current = ttsController; + ttsControllerRef.current.speak(ssml); + setIsPlaying(true); + } catch (error) { + eventDispatcher.dispatch('toast', { + message: _('TTS not supported in this device'), + type: 'error', + }); + console.error(error); + } }; const handleTogglePlay = async () => { @@ -59,7 +69,7 @@ const TTSControl = () => { ttsController.pause(); setIsPlaying(false); } else { - await ttsController.start(); + ttsController.start(); setIsPlaying(true); } }; diff --git a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts index 36f4c011..bba9ff4e 100644 --- a/apps/readest-app/src/app/reader/hooks/useProgressSync.ts +++ b/apps/readest-app/src/app/reader/hooks/useProgressSync.ts @@ -7,12 +7,10 @@ import { useReaderStore } from '@/store/readerStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { deserializeConfig, serializeConfig } from '@/utils/serializer'; +import { eventDispatcher } from '@/utils/event'; import { DEFAULT_BOOK_SEARCH_CONFIG, SYNC_PROGRESS_INTERVAL_SEC } from '@/services/constants'; -export const useProgressSync = ( - bookKey: string, - setToastMessage?: React.Dispatch>, -) => { +export const useProgressSync = (bookKey: string) => { const _ = useTranslation(); const { getConfig, setConfig } = useBookDataStore(); const { getView } = useReaderStore(); @@ -98,7 +96,10 @@ export const useProgressSync = ( const configFraction = config!.progress![0] / config!.progress![1]; if (syncedFraction > configFraction) { view?.goToFraction(syncedFraction); - setToastMessage?.(_('Reading progress synced')); + eventDispatcher.dispatch('toast', { + type: 'success', + message: _('Reading progress synced'), + }); } } } diff --git a/apps/readest-app/src/components/Toast.tsx b/apps/readest-app/src/components/Toast.tsx index 6249d522..5dfcd9a2 100644 --- a/apps/readest-app/src/components/Toast.tsx +++ b/apps/readest-app/src/components/Toast.tsx @@ -1,16 +1,65 @@ +import { eventDispatcher } from '@/utils/event'; import clsx from 'clsx'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; -const Toast: React.FC<{ message: string; toastClass?: string; alertClass?: string }> = ({ - message, - toastClass, - alertClass, -}) => ( -
-
- {message} -
-
-); +export type ToastType = 'info' | 'success' | 'warning' | 'error'; -export default Toast; +export const Toast = () => { + const [toastMessage, setToastMessage] = useState(''); + const toastType = useRef('info'); + const toastTimeout = useRef(5000); + const messageClass = useRef(''); + const toastDismissTimeout = useRef | null>(null); + const toastClassMap = { + info: 'toast-info toast-center toast-middle', + success: 'toast-success toast-top toast-end pt-11', + warning: 'toast-warning toast-top toast-end pt-11', + error: 'toast-error toast-top toast-end pt-11', + }; + const alertClassMap = { + info: 'alert-primary', + success: 'alert-success', + warning: 'alert-warning', + error: 'alert-error', + }; + + useEffect(() => { + if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current); + toastDismissTimeout.current = setTimeout(() => setToastMessage(''), toastTimeout.current); + return () => { + if (toastDismissTimeout.current) clearTimeout(toastDismissTimeout.current); + }; + }, [toastMessage]); + + const handleShowToast = async (event: CustomEvent) => { + const { message, type = 'info', timeout = 5000, className = '' } = event.detail; + setToastMessage(message); + toastType.current = type; + toastTimeout.current = timeout; + messageClass.current = className; + }; + + useEffect(() => { + eventDispatcher.on('toast', handleShowToast); + return () => { + eventDispatcher.off('toast', handleShowToast); + }; + }, []); + + return ( + toastMessage && ( +
+
+ + {toastMessage} + +
+
+ ) + ); +}; diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index c3f88e64..21e06cab 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -72,7 +72,7 @@ function findMark(charIndex: number, marks: TTSMark[]) { async function* speakWithBoundary(ssml: string) { const lang = getXmlLang(ssml); const { plainText, marks } = parseSSMLMarks(ssml); - // console.log('text', plainText, marks); + // console.log('text', ssml, plainText, marks); const synth = window.speechSynthesis; const utterance = new SpeechSynthesisUtterance(plainText); @@ -85,7 +85,7 @@ async function* speakWithBoundary(ssml: string) { utterance.onboundary = (event: SpeechSynthesisEvent) => { const mark = findMark(event.charIndex, marks); - // console.log('boundary', mark); + // console.log('boundary', event.charIndex, mark); queue.enqueue({ type: 'boundary', speaking: true, @@ -163,7 +163,6 @@ export class WebSpeechClient implements TTSClient { async setRate(rate: number) { // The Web Speech API uses utterance.rate in [0.1 .. 10], - // you might need to scale your “rate” to match. if (this.#utterance) this.#utterance.rate = rate / 10; }