Compare commits

..

15 Commits

Author SHA1 Message Date
Huang Xin 60a9eb5b57 Release version 0.9.5 (#159) 2025-01-15 17:55:50 +01:00
Egan Gumiwang Pratama Bisma 46fdea3522 feat: provide reading progress indicator for each books in the bookshelf (#153)
* feat: provide reading progress indicator for each books in the bookshelf

* refactor: simplify bookshelf component and remove unused hook; enhance reading progress calculation

- Removed the `useBookConfigLoader` hook as it was no longer needed.
- Simplified the bookshelf items generation by eliminating the use of `useMemo`.
- Improved the reading progress calculation in the `ReadingProgress` component to directly use the book's progress data.
- Updated the `Book` type to include a `progress` field for better tracking of reading status.

* fix formatting for readerStore
2025-01-15 17:17:07 +01:00
Huang Xin ba82203629 Set webpage title to book title (#155) 2025-01-14 17:02:23 +01:00
Huang Xin 8dbcf25331 Toggle TTS stop when it's already running (#154) 2025-01-14 15:32:47 +01:00
Huang Xin 7e9ddcd7cc Preload the next two paragraphs in advance for TTS playback (#151) 2025-01-14 01:45:54 +01:00
Huang Xin 211fc2cd22 Add more system fonts (#150) 2025-01-13 23:27:52 +01:00
Huang Xin 3578d7eba8 Handle publisher with language map in book metadata (#149) 2025-01-13 22:53:58 +01:00
Huang Xin d220adbfdf TTS UX enhancements (#148)
* Fix toast of PDF unsupported for TTS

* Use vertical scroll for large dropdown menus
2025-01-13 20:04:04 +01:00
Huang Xin 752f4449b4 Add fallback fonts for GB18030-2022 L3 charsets (#147)
* Add fallback fonts for GB18030-2022 L3 charsets

* Update README to credit fonts used in this application
2025-01-13 19:14:43 +01:00
Huang Xin 66f369b269 Fix multiple voices when setting rate and voices in Firefox (#146) 2025-01-13 00:19:49 +01:00
Huang Xin 2251d65de4 Release hotfix of occasionally speak not stop when setting rate and voice 2025-01-11 19:08:23 +01:00
Huang Xin 463ea39467 Fix occasionally current speak not stopped when changing voice or rate (#144)
* Fix occasionally current speak not stopped when changing voice or rate

* Inherit tts rate when changing tts engine
2025-01-11 18:11:14 +01:00
Huang Xin 87bc842525 Set default sidebar width to 15% of screen width (#143) 2025-01-11 11:34:43 +01:00
Huang Xin 2ad67a0503 Init TTSController async when showing TTS indicator and add more voices (#142) 2025-01-11 11:15:21 +01:00
Huang Xin 9f894e1d9e Use default voice in Edge TTS (#141) 2025-01-10 15:26:01 +01:00
38 changed files with 593 additions and 153 deletions
+9 -1
View File
@@ -182,12 +182,20 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
The following JavaScript libraries are bundled in this software:
The following libraries and software are used in this software:
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
[Bitter](https://fonts.google.com/?query=Bitter), [Fira Code](https://fonts.google.com/?query=Fira+Code), [Literata](https://fonts.google.com/?query=Literata), [Merriweather](https://fonts.google.com/?query=Merriweather), [Noto Sans](https://fonts.google.com/?query=Noto+Sans), [Roboto](https://fonts.google.com/?query=Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.2",
"version": "0.9.5",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Willkommen in Ihrer Bibliothek. Sie können hier Ihre Bücher importieren und jederzeit lesen.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Schreibmodus",
"Your Library": "Ihre Bibliothek"
"Your Library": "Ihre Bibliothek",
"TTS not supported for PDF": "TTS wird für PDF nicht unterstützt"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenido a tu biblioteca. Puedes importar tus libros aquí y leerlos en cualquier momento.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modo de escritura",
"Your Library": "Tu biblioteca"
}
"Your Library": "Tu biblioteca",
"TTS not supported for PDF": "TTS no es compatible con PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Bienvenue dans votre bibliothèque. Vous pouvez importer vos livres ici et les lire à tout moment.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Mode écriture",
"Your Library": "Votre bibliothèque"
"Your Library": "Votre bibliothèque",
"TTS not supported for PDF": "La synthèse vocale n'est pas prise en charge pour les fichiers PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Selamat datang di perpustakaan Anda. Anda dapat mengimpor buku-buku Anda di sini dan membacanya kapan saja.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Mode Menulis",
"Your Library": "Perpustakaan Anda"
"Your Library": "Perpustakaan Anda",
"TTS not supported for PDF": "TTS tidak didukung untuk PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Benvenuto nella tua biblioteca. Puoi importare i tuoi libri qui e leggerli in qualsiasi momento.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Modalità scrittura",
"Your Library": "La tua biblioteca"
"Your Library": "La tua biblioteca",
"TTS not supported for PDF": "TTS non supportato per PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "ライブラリーへようこそ。ここに書籍をインポートして、いつでも読むことができます。",
"Wikipedia": "ウィキペディア",
"Writing Mode": "書き込みモード",
"Your Library": "ライブラリー"
"Your Library": "ライブラリー",
"TTS not supported for PDF": "PDFではTTSがサポートされていません"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "내 서재에 오신 것을 환영합니다. 여기에서 책을 가져와 언제든지 읽을 수 있습니다.",
"Wikipedia": "위키피디아",
"Writing Mode": "글쓰기 모드",
"Your Library": "내 서재"
"Your Library": "내 서재",
"TTS not supported for PDF": "PDF에서 TTS가 지원되지 않음"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Bem-vindo à sua biblioteca. Você pode importar seus livros aqui e lê-los a qualquer momento.",
"Wikipedia": "Wikipédia",
"Writing Mode": "Modo de Escrita",
"Your Library": "Sua Biblioteca"
"Your Library": "Sua Biblioteca",
"TTS not supported for PDF": "TTS não suportado para PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Добро пожаловать в вашу библиотеку. Вы можете импортировать сюда книги и читать их в любое время.",
"Wikipedia": "Википедия",
"Writing Mode": "Режим записи",
"Your Library": "Ваша библиотека"
"Your Library": "Ваша библиотека",
"TTS not supported for PDF": "TTS не поддерживается для PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Kütüphanenize hoş geldiniz. Buradan kitaplarınızı içe aktarabilir ve istediğiniz zaman okuyabilirsiniz.",
"Wikipedia": "Vikipedi",
"Writing Mode": "Yazma Modu",
"Your Library": "Kütüphaneniz"
"Your Library": "Kütüphaneniz",
"TTS not supported for PDF": "PDF için TTS desteklenmiyor"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "Chào mừng đến với thư viện của bạn. Bạn có thể nhập sách vào đây và đọc bất cứ lúc nào.",
"Wikipedia": "Wikipedia",
"Writing Mode": "Chế độ viết",
"Your Library": "Thư viện của bạn"
"Your Library": "Thư viện của bạn",
"TTS not supported for PDF": "TTS không được hỗ trợ cho PDF"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "书库空空如也,您可以导入您的书籍并随时阅读。",
"Wikipedia": "维基百科",
"Writing Mode": "排版模式",
"Your Library": "书库"
"Your Library": "书库",
"TTS not supported for PDF": "PDF 文档暂不支持 TTS"
}
@@ -129,5 +129,6 @@
"Welcome to your library. You can import your books here and read them anytime.": "書庫空空如也,您可以導入您的書籍並隨時閱讀。",
"Wikipedia": "維基百科",
"Writing Mode": "排版模式",
"Your Library": "書庫"
"Your Library": "書庫",
"TTS not supported for PDF": "PDF 文檔暫不支持 TTS"
}
+15
View File
@@ -1,5 +1,20 @@
{
"releases": {
"0.9.5": {
"date": "2025-01-15",
"notes": [
"Various TTS related UX enhancements",
"Add more system fonts as custom fonts",
"Show reading progress in bookshelf"
]
},
"0.9.3": {
"date": "2025-01-11",
"notes": [
"Hotfix occasionally current speak not stopped when changing voice or rate",
"Inherit tts rate when changing tts engine"
]
},
"0.9.2": {
"date": "2025-01-10",
"notes": [
+3 -2
View File
@@ -14,10 +14,11 @@
"windows": [],
"security": {
"csp": {
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost https://fonts.gstatic.com https://db.onlinewebfonts.com",
"default-src": "'self' 'unsafe-inline' blob: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: asset: http://asset.localhost ipc: http://ipc.localhost https://*.sentry.io https://*.posthog.com https://*.deepl.com https://*.wikipedia.org https://*.wiktionary.org https://*.supabase.co https://*.readest.com wss://speech.platform.bing.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net",
"font-src": "'self' data: asset: http://asset.localhost tauri: https://fonts.gstatic.com https://db.onlinewebfonts.com https://cdn.jsdelivr.net",
"frame-src": "'self' blob: asset: http://asset.localhost",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com"
},
@@ -25,6 +25,7 @@ import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import Alert from '@/components/Alert';
import Spinner from '@/components/Spinner';
import BookDetailModal from '@/components/BookDetailModal';
import ReadingProgress from './ReadingProgress';
type BookshelfItem = Book | BooksGroup;
@@ -242,17 +243,50 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
)}
</div>
</div>
<div className='flex flex-row items-center justify-between p-0 pt-2'>
<h4 className='card-title line-clamp-1 text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
{isWebAppPlatform() && (
<div
className='show-detail-button self-start opacity-0 group-hover:opacity-100'
role='button'
onClick={showBookDetailsModal.bind(null, item as Book)}
>
<CiCircleMore size={15} />
<div
className={clsx(
'flex w-full p-0 pt-2',
isWebAppPlatform() ? 'flex-col' : 'flex-row justify-between',
)}
>
<div className='min-w-0 flex-1'>
<h4 className='block overflow-hidden text-ellipsis whitespace-nowrap text-[0.6em] text-xs font-semibold'>
{(item as Book).title}
</h4>
</div>
{item.progress && (
<div className={'flex items-center justify-between'}>
<ReadingProgress book={item as Book} />
{isWebAppPlatform() && (
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
)}
</div>
)}
{!item.progress && isWebAppPlatform() && (
<div className={'flex items-center justify-end'}>
<button
type='button'
className='show-detail-button opacity-0 group-hover:opacity-100'
onClick={showBookDetailsModal.bind(null, item as Book)}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBookDetailsModal(item as Book);
}
}}
>
<CiCircleMore size={15} />
</button>
</div>
)}
</div>
@@ -270,6 +304,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({ libraryBooks, isSelectMode, onImp
className='h-48 w-full object-cover'
/>
</figure>
<div className='card-body p-4'>
<h3 className='card-title line-clamp-2 text-sm'>{book.title}</h3>
<p className='text-neutral-content line-clamp-1 text-xs'>{book.author}</p>
<ReadingProgress book={book} />
</div>
</div>
))}
<h2 className='mb-2 text-lg font-bold'>{(item as BooksGroup).name}</h2>
@@ -0,0 +1,43 @@
import type React from 'react';
import { memo, useMemo } from 'react';
import type { Book } from '@/types/book';
interface ReadingProgressProps {
book: Book;
}
const getProgressPercentage = (book: Book) => {
if (!book.progress || !book.progress[1]) {
return null;
}
if (book.progress && book.progress[1] === 1) {
return 100;
}
return Math.round((book.progress[0] / book.progress[1]) * 100);
};
const ReadingProgress: React.FC<ReadingProgressProps> = memo(
({ book }) => {
const progressPercentage = useMemo(() => getProgressPercentage(book), [book]);
if (!progressPercentage) {
return null;
}
return (
<div className='text-neutral-content/70 flex justify-between text-xs'>
<span>{progressPercentage}%</span>
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.book.hash === nextProps.book.hash &&
prevProps.book.updatedAt === nextProps.book.updatedAt
);
},
);
ReadingProgress.displayName = 'ReadingProgress';
export default ReadingProgress;
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSidebarStore } from '@/store/sidebarStore';
import FoliateViewer from './FoliateViewer';
import getGridTemplate from '@/utils/grid';
import SectionInfo from './SectionInfo';
@@ -22,9 +23,18 @@ interface BooksGridProps {
const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook }) => {
const { getConfig, getBookData } = useBookDataStore();
const { getProgress, getViewState, getViewSettings } = useReaderStore();
const { sideBarBookKey } = useSidebarStore();
const { isFontLayoutSettingsDialogOpen, setFontLayoutSettingsDialogOpen } = useSettingsStore();
const gridTemplate = getGridTemplate(bookKeys.length, window.innerWidth / window.innerHeight);
useEffect(() => {
if (!sideBarBookKey) return;
const bookData = getBookData(sideBarBookKey);
if (!bookData || !bookData.book) return;
document.title = bookData.book.title;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sideBarBookKey]);
return (
<div
className='grid h-full flex-grow'
@@ -47,7 +47,11 @@ const FooterBar: React.FC<FooterBarProps> = ({ bookKey, pageinfo, isHoveredAnim
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
eventDispatcher.dispatch('tts-speak', { bookKey, range });
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
}
};
const pageinfoValid = pageinfo && pageinfo.total > 0 && pageinfo.current >= 0;
@@ -57,7 +57,7 @@ const FontDropdown: React.FC<DropdownProps> = ({
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 menu rounded-box relative z-[1] overflow-y-scroll shadow',
'!mr-5 mb-[-46px] max-h-80 w-[292px]',
'!mr-5 mb-[-46px] inline max-h-80 w-[250px] overflow-y-scroll',
)}
>
{moreOptions.map((option) => (
@@ -39,7 +39,7 @@ const SettingsDialog: React.FC<{ bookKey: string; config: BookConfig }> = ({ boo
return (
<dialog className='modal modal-open min-w-90 w-full !bg-[rgba(0,0,0,0.2)]'>
<div className='modal-box settings-content flex h-[65%] w-1/2 min-w-[540px] max-w-full flex-col p-0'>
<div className='modal-box settings-content flex h-[65%] w-1/2 min-w-[480px] max-w-full flex-col p-0'>
<div className='dialog-header bg-base-100 sticky top-0 z-10 flex items-center justify-center px-4 pt-2'>
<div className='dialog-tabs flex h-10 max-w-[80%] flex-grow items-center justify-around'>
<button
@@ -1,10 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { TTSController } from '@/services/tts/TTSController';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/ui';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -15,11 +17,13 @@ const POPUP_PADDING = 10;
const TTSControl = () => {
const _ = useTranslation();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
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 [panelPosition, setPanelPosition] = useState<Position>();
const [trianglePosition, setTrianglePosition] = useState<Position>();
@@ -39,9 +43,11 @@ const TTSControl = () => {
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
}, []);
@@ -50,7 +56,15 @@ const TTSControl = () => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!view || !viewSettings) return;
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);
@@ -58,6 +72,7 @@ const TTSControl = () => {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setShowIndicator(true);
try {
const ttsController = new TTSController(view);
@@ -84,30 +99,34 @@ const TTSControl = () => {
};
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (bookKey === bookKey) {
const { bookKey: stopBookKey } = event.detail;
if (bookKey === stopBookKey) {
handleStop();
}
};
const handleQueryIsSpeaking = () => {
return !!ttsControllerRef.current;
};
const handleTogglePlay = async () => {
const ttsController = ttsControllerRef.current;
if (!ttsController) return;
if (isPlaying) {
ttsController.pause();
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') {
ttsController.resume();
await ttsController.resume();
} else {
ttsController.start();
await ttsController.start();
}
setIsPlaying(true);
setIsPaused(false);
}
};
@@ -128,39 +147,49 @@ const TTSControl = () => {
const handleStop = async () => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
ttsController.stop();
await ttsController.stop();
ttsControllerRef.current = null;
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
setShowIndicator(false);
}
};
// rate range: 0.5 - 3, 1.0 is normal speed
const handleSetRate = async (rate: number) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setRate(rate);
ttsController.start();
} else {
ttsController.setRate(rate);
// 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),
[],
);
const handleSetVoice = async (voice: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
if (ttsController.state === 'playing') {
ttsController.pause();
ttsController.setVoice(voice);
ttsController.start();
} else {
ttsController.setVoice(voice);
// 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;
@@ -219,7 +248,7 @@ const TTSControl = () => {
onContextMenu={handleDismissPopup}
/>
)}
{ttsControllerRef.current && (
{showIndicator && (
<div ref={iconRef} className='absolute bottom-12 right-6 h-12 w-12'>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
</div>
@@ -130,8 +130,8 @@ const TTSPanel = ({
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu rounded-box absolute right-0 z-[1] shadow',
'mt-4 max-h-96 w-[250px] overflow-y-scroll',
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
@@ -3,13 +3,13 @@ import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import { Book } from '@/types/book';
import { BookDoc } from '@/libs/document';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { formatDate, formatSubject } from '@/utils/book';
import { formatDate, formatLanguage, formatPublisher, formatSubject } from '@/utils/book';
import WindowButtons from '@/components/WindowButtons';
import Spinner from './Spinner';
import { BookDoc } from '@/libs/document';
interface BookDetailModalProps {
book: Book;
@@ -123,7 +123,7 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='overflow-hidden'>
<span className='font-bold'>{_('Publisher:')}</span>
<p className='text-neutral-content line-clamp-1 text-sm'>
{bookMeta.publisher || _('Unknown')}
{formatPublisher(bookMeta.publisher || '') || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
@@ -141,7 +141,9 @@ const BookDetailModal = ({ book, isOpen, onClose }: BookDetailModalProps) => {
<div className='grid grid-cols-3 gap-4'>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Language:')}</span>
<p className='text-neutral-content text-sm'>{bookMeta.language || _('Unknown')}</p>
<p className='text-neutral-content text-sm'>
{formatLanguage(bookMeta.language) || _('Unknown')}
</p>
</div>
<div className='overflow-hidden'>
<span className='font-bold'>{_('Identifier:')}</span>
+91 -10
View File
@@ -6,12 +6,74 @@ const EDGE_SPEECH_URL =
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
const EDGE_API_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
const EDGE_TTS_VOICES = {
'af-ZA': ['af-ZA-AdriNeural', 'af-ZA-WillemNeural'],
'am-ET': ['am-ET-AmehaNeural', 'am-ET-MekdesNeural'],
'ar-AE': ['ar-AE-FatimaNeural', 'ar-AE-HamdanNeural'],
'ar-BH': ['ar-BH-AliNeural', 'ar-BH-LailaNeural'],
'ar-DZ': ['ar-DZ-AminaNeural', 'ar-DZ-IsmaelNeural'],
'ar-EG': ['ar-EG-SalmaNeural', 'ar-EG-ShakirNeural'],
'ar-IQ': ['ar-IQ-BasselNeural', 'ar-IQ-RanaNeural'],
'ar-JO': ['ar-JO-SanaNeural', 'ar-JO-TaimNeural'],
'ar-KW': ['ar-KW-FahedNeural', 'ar-KW-NouraNeural'],
'ar-LB': ['ar-LB-LaylaNeural', 'ar-LB-RamiNeural'],
'ar-LY': ['ar-LY-ImanNeural', 'ar-LY-OmarNeural'],
'ar-MA': ['ar-MA-JamalNeural', 'ar-MA-MounaNeural'],
'ar-OM': ['ar-OM-AbdullahNeural', 'ar-OM-AyshaNeural'],
'ar-QA': ['ar-QA-AmalNeural', 'ar-QA-MoazNeural'],
'ar-SA': ['ar-SA-HamedNeural', 'ar-SA-ZariyahNeural'],
'de-DE': ['de-DE-AmalaNeural', 'de-DE-ConradNeural', 'de-DE-KatjaNeural', 'de-DE-KillianNeural'],
'ar-SY': ['ar-SY-AmanyNeural', 'ar-SY-LaithNeural'],
'ar-TN': ['ar-TN-HediNeural', 'ar-TN-ReemNeural'],
'ar-YE': ['ar-YE-MaryamNeural', 'ar-YE-SalehNeural'],
'az-AZ': ['az-AZ-BabekNeural', 'az-AZ-BanuNeural'],
'bg-BG': ['bg-BG-BorislavNeural', 'bg-BG-KalinaNeural'],
'bn-BD': ['bn-BD-NabanitaNeural', 'bn-BD-PradeepNeural'],
'bn-IN': ['bn-IN-BashkarNeural', 'bn-IN-TanishaaNeural'],
'bs-BA': ['bs-BA-GoranNeural', 'bs-BA-VesnaNeural'],
'ca-ES': ['ca-ES-EnricNeural', 'ca-ES-JoanaNeural'],
'cs-CZ': ['cs-CZ-AntoninNeural', 'cs-CZ-VlastaNeural'],
'cy-GB': ['cy-GB-AledNeural', 'cy-GB-NiaNeural'],
'da-DK': ['da-DK-ChristelNeural', 'da-DK-JeppeNeural'],
'de-AT': ['de-AT-IngridNeural', 'de-AT-JonasNeural'],
'de-CH': ['de-CH-JanNeural', 'de-CH-LeniNeural'],
'de-DE': [
'de-DE-AmalaNeural',
'de-DE-ConradNeural',
'de-DE-FlorianMultilingualNeural',
'de-DE-KatjaNeural',
'de-DE-KillianNeural',
'de-DE-SeraphinaMultilingualNeural',
],
'el-GR': ['el-GR-AthinaNeural', 'el-GR-NestorasNeural'],
'en-AU': ['en-AU-NatashaNeural', 'en-AU-WilliamNeural'],
'en-CA': ['en-CA-ClaraNeural', 'en-CA-LiamNeural'],
'en-GB': [
'en-GB-LibbyNeural',
'en-GB-MaisieNeural',
'en-GB-RyanNeural',
'en-GB-SoniaNeural',
'en-GB-ThomasNeural',
],
'en-HK': ['en-HK-SamNeural', 'en-HK-YanNeural'],
'en-IE': ['en-IE-ConnorNeural', 'en-IE-EmilyNeural'],
'en-IN': ['en-IN-NeerjaExpressiveNeural', 'en-IN-NeerjaNeural', 'en-IN-PrabhatNeural'],
'en-KE': ['en-KE-AsiliaNeural', 'en-KE-ChilembaNeural'],
'en-NG': ['en-NG-AbeoNeural', 'en-NG-EzinneNeural'],
'en-NZ': ['en-NZ-MitchellNeural', 'en-NZ-MollyNeural'],
'en-PH': ['en-PH-JamesNeural', 'en-PH-RosaNeural'],
'en-SG': ['en-SG-LunaNeural', 'en-SG-WayneNeural'],
'en-TZ': ['en-TZ-ElimuNeural', 'en-TZ-ImaniNeural'],
'en-US': [
'en-US-AriaNeural',
'en-US-AnaNeural',
'en-US-AndrewMultilingualNeural',
'en-US-AndrewNeural',
'en-US-AriaNeural',
'en-US-AvaMultilingualNeural',
'en-US-AvaNeural',
'en-US-BrianMultilingualNeural',
'en-US-BrianNeural',
'en-US-ChristopherNeural',
'en-US-EmmaMultilingualNeural',
'en-US-EmmaNeural',
'en-US-EricNeural',
'en-US-GuyNeural',
'en-US-JennyNeural',
@@ -19,23 +81,42 @@ const EDGE_TTS_VOICES = {
'en-US-RogerNeural',
'en-US-SteffanNeural',
],
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural'],
'fr-FR': ['fr-FR-DeniseNeural', 'fr-FR-EloiseNeural', 'fr-FR-HenriNeural'],
'es-AR': ['es-AR-ElenaNeural', 'es-AR-TomasNeural'],
'es-BO': ['es-BO-MarceloNeural', 'es-BO-SofiaNeural'],
'es-CL': ['es-CL-CatalinaNeural', 'es-CL-LorenzoNeural'],
'es-CO': ['es-CO-GonzaloNeural', 'es-CO-SalomeNeural'],
'es-CR': ['es-CR-JuanNeural', 'es-CR-MariaNeural'],
'es-CU': ['es-CU-BelkysNeural', 'es-CU-ManuelNeural'],
'es-DO': ['es-DO-EmilioNeural', 'es-DO-RamonaNeural'],
'es-EC': ['es-EC-AndreaNeural', 'es-EC-LuisNeural'],
'es-ES': ['es-ES-AlvaroNeural', 'es-ES-ElviraNeural', 'es-ES-XimenaNeural'],
'es-US': ['es-US-AlonsoNeural', 'es-US-PalomaNeural'],
'fr-BE': ['fr-BE-CharlineNeural', 'fr-BE-GerardNeural'],
'fr-CA': ['fr-CA-AntoineNeural', 'fr-CA-JeanNeural', 'fr-CA-SylvieNeural', 'fr-CA-ThierryNeural'],
'fr-CH': ['fr-CH-ArianeNeural', 'fr-CH-FabriceNeural'],
'fr-FR': [
'fr-FR-DeniseNeural',
'fr-FR-EloiseNeural',
'fr-FR-HenriNeural',
'fr-FR-RemyMultilingualNeural',
'fr-FR-VivienneMultilingualNeural',
],
'ja-JP': ['ja-JP-KeitaNeural', 'ja-JP-NanamiNeural'],
'ko-KR': ['ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural'],
'ru-RU': ['ru-RU-DmitryNeural', 'ru-RU-SvetlanaNeural'],
'ko-KR': ['ko-KR-HyunsuMultilingualNeural', 'ko-KR-InJoonNeural', 'ko-KR-SunHiNeural'],
'pt-BR': ['pt-BR-AntonioNeural', 'pt-BR-FranciscaNeural', 'pt-BR-ThalitaMultilingualNeural'],
'pt-PT': ['pt-PT-DuarteNeural', 'pt-PT-RaquelNeural'],
'zh-CN': [
'zh-CN-XiaoxiaoNeural',
'zh-CN-XiaoyiNeural',
'zh-CN-YunjianNeural',
'zh-CN-liaoning-XiaobeiNeural',
'zh-CN-shaanxi-XiaoniNeural',
'zh-CN-YunxiNeural',
'zh-CN-YunxiaNeural',
'zh-CN-YunyangNeural',
'zh-CN-liaoning-XiaobeiNeural',
'zh-CN-shaanxi-XiaoniNeural',
],
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-YunJheNeural', 'zh-TW-HsiaoYuNeural'],
'zh-HK': ['zh-HK-HiuGaaiNeural', 'zh-HK-HiuMaanNeural', 'zh-HK-WanLungNeural'],
'zh-TW': ['zh-TW-HsiaoChenNeural', 'zh-TW-HsiaoYuNeural', 'zh-TW-YunJheNeural'],
};
const genVoiceList = (voices: Record<string, string[]>) => {
+31 -1
View File
@@ -15,7 +15,7 @@ export const SUPPORTED_FILE_EXTS = ['epub', 'mobi', 'azw', 'azw3', 'fb2', 'cbz',
export const FILE_ACCEPT_FORMATS = SUPPORTED_FILE_EXTS.map((ext) => `.${ext}`).join(', ');
export const DEFAULT_READSETTINGS: ReadSettings = {
sideBarWidth: '25%',
sideBarWidth: '15%',
isSideBarPinned: true,
notebookWidth: '25%',
isNotebookPinned: false,
@@ -95,6 +95,8 @@ export const SANS_SERIF_FONTS = ['Roboto', 'Noto Sans', 'Open Sans', 'Helvetica'
export const MONOSPACE_FONTS = ['Fira Code', 'Lucida Console', 'Consolas', 'Courier New'];
export const FALLBACK_FONTS = ['MiSans L3'];
export const WINDOWS_FONTS = [
'Arial',
'Arial Black',
@@ -109,6 +111,7 @@ export const WINDOWS_FONTS = [
'Corbel',
'Courier New',
'Ebrima',
'FangSong',
'Franklin Gothic Medium',
'Gabriola',
'Gadugi',
@@ -117,6 +120,7 @@ export const WINDOWS_FONTS = [
'Impact',
'Ink Free',
'Javanese Text',
'KaiTi',
'Leelawadee UI',
'Lucida Console',
'Lucida Sans Unicode',
@@ -130,13 +134,16 @@ export const WINDOWS_FONTS = [
'Microsoft Tai Le',
'Microsoft YaHei',
'Microsoft Yi Baiti',
'MingLiU',
'MingLiU-ExtB',
'Mongolian Baiti',
'MS Gothic',
'MV Boli',
'Myanmar Text',
'Nirmala UI',
'NSimSun',
'Palatino Linotype',
'PMingLiU',
'Segoe MDL2 Assets',
'Segoe Print',
'Segoe Script',
@@ -144,6 +151,7 @@ export const WINDOWS_FONTS = [
'Segoe UI Historic',
'Segoe UI Emoji',
'Segoe UI Symbol',
'SimHei',
'SimSun',
'Sitka',
'Sylfaen',
@@ -169,6 +177,7 @@ export const MACOS_FONTS = [
'Avenir Next',
'Avenir Next Condensed',
'Baskerville',
'BiauKai',
'Big Caslon',
'Bodoni 72',
'Bodoni 72 Oldstyle',
@@ -191,11 +200,15 @@ export const MACOS_FONTS = [
'Geneva',
'Georgia',
'Gill Sans',
'Heiti SC',
'Heiti TC',
'Helvetica',
'Helvetica Neue',
'Herculanum',
'Hoefler Text',
'Impact',
'Kaiti SC',
'Kaiti TC',
'Lucida Grande',
'Luminari',
'Marker Felt',
@@ -206,12 +219,21 @@ export const MACOS_FONTS = [
'Optima',
'Palatino',
'Papyrus',
'PingFang HK',
'PingFang SC',
'PingFang TC',
'Phosphate',
'Rockwell',
'Savoye LET',
'SignPainter',
'Skia',
'Snell Roundhand',
'Songti SC',
'Songti TC',
'STFangsong',
'STKaiti',
'STSong',
'STXihei',
'Tahoma',
'Times',
'Times New Roman',
@@ -241,14 +263,22 @@ export const LINUX_FONTS = [
'Liberation Serif',
'Noto Mono',
'Noto Sans',
'Noto Sans CJK SC',
'Noto Sans CJK TC',
'Noto Serif',
'Noto Serif CJK SC',
'Noto Serif CJK TC',
'Open Sans',
'Poppins',
'Source Han Sans',
'Source Han Serif',
'Symbola',
'Times New Roman',
'Ubuntu',
'Ubuntu Mono',
'Wingdings',
'WenQuanYi Micro Hei',
'WenQuanYi Zen Hei',
];
export const ONE_COLUMN_MAX_INLINE_SIZE = 9999;
@@ -44,7 +44,11 @@ export class EdgeTTSClient implements TTSClient {
return { lang, text, voice: voiceId, rate: this.#rate, pitch: this.#pitch } as EdgeTTSPayload;
};
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
async *speak(
ssml: string,
signal: AbortSignal,
preload = false,
): AsyncGenerator<TTSMessageEvent> {
const { marks } = parseSSMLMarks(ssml);
const lang = parseSSMLLang(ssml) || 'en';
@@ -57,18 +61,29 @@ export class EdgeTTSClient implements TTSClient {
voiceId = this.#voice.id;
}
this.stopInternal();
// Preloading for longer ssml
if (marks.length > 1) {
for (const mark of marks.slice(1)) {
this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((error) => {
console.warn('Error preloading mark:', mark, error);
if (preload) {
for (const mark of marks) {
await this.#edgeTTS.createAudio(this.getPayload(lang, mark.text, voiceId)).catch((err) => {
console.warn('Error preloading:', err);
});
}
yield {
code: 'end',
message: 'Preload finished',
};
return;
} else {
await this.stopInternal();
}
for (const mark of marks) {
if (signal.aborted) {
yield {
code: 'error',
message: 'Aborted',
};
break;
}
try {
this.#audioBuffer = await this.#edgeTTS.createAudio(
this.getPayload(lang, mark.text, voiceId),
@@ -95,6 +110,13 @@ export class EdgeTTSClient implements TTSClient {
message: `Chunk finished: ${mark.name}`,
});
};
if (signal.aborted) {
resolve({
code: 'error',
message: 'Aborted',
});
return;
}
this.#sourceNode.start(0);
this.#isPlaying = true;
this.#startedAt = this.#audioContext.currentTime;
@@ -117,7 +139,7 @@ export class EdgeTTSClient implements TTSClient {
break;
}
this.stopInternal();
await this.stopInternal();
}
}
@@ -136,10 +158,10 @@ export class EdgeTTSClient implements TTSClient {
}
async stop() {
this.stopInternal();
await this.stopInternal();
}
private stopInternal() {
private async stopInternal() {
this.#isPlaying = false;
this.#pausedAt = 0;
this.#startedAt = 0;
@@ -147,13 +169,15 @@ export class EdgeTTSClient implements TTSClient {
try {
this.#sourceNode.stop();
} catch (err) {
console.error('Error stopping source node:', err);
if (!(err instanceof Error) || err.name !== 'InvalidStateError') {
console.log('Error stopping source node:', err);
}
}
this.#sourceNode.disconnect();
this.#sourceNode = null;
}
if (this.#audioContext) {
this.#audioContext.close();
await this.#audioContext.close();
this.#audioContext = null;
}
this.#audioBuffer = null;
@@ -17,7 +17,7 @@ export interface TTSVoice {
export interface TTSClient {
init(): Promise<boolean>;
speak(ssml: string): AsyncIterable<TTSMessageEvent>;
speak(ssml: string, signal: AbortSignal, preload?: boolean): AsyncIterable<TTSMessageEvent>;
pause(): Promise<void>;
resume(): Promise<void>;
stop(): Promise<void>;
@@ -9,13 +9,16 @@ type TTSState =
| 'paused'
| 'backward-paused'
| 'forward-paused'
| 'setrate-paused'
| 'setvoice-paused';
export class TTSController extends EventTarget {
state: TTSState = 'stopped';
view: FoliateView;
#nossmlCnt: number = 0;
#currentSpeakAbortController: AbortController | null = null;
ttsRate: number = 1.0;
ttsClient: TTSClient;
ttsWebClient: TTSClient;
ttsEdgeClient: TTSClient;
@@ -48,43 +51,73 @@ export class TTSController extends EventTarget {
if (!supportedGranularities.includes(granularity)) {
granularity = supportedGranularities[0]!;
}
await this.ttsClient.stop();
await this.view.initTTS(granularity);
}
async preloadSSML(ssml: string | undefined) {
if (!ssml) return;
const iter = await this.ttsClient.speak(ssml, new AbortController().signal, true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for await (const _ of iter);
}
async preloadNextSSML(count: number) {
const tts = this.view.tts;
if (!tts) return;
let preloaded = 0;
for (let i = 0; i < count; i++) {
const ssml = tts.next();
this.preloadSSML(ssml);
if (ssml) preloaded++;
}
for (let i = 0; i < preloaded; i++) {
tts.prev();
}
}
async #speak(ssml: string | undefined | Promise<string>) {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
if (this.#nossmlCnt < 10 && this.state === 'playing') {
await this.view.next(1);
this.forward();
}
return;
} else {
this.#nossmlCnt = 0;
}
await this.stop();
this.#currentSpeakAbortController = new AbortController();
const { signal } = this.#currentSpeakAbortController;
const iter = await this.ttsClient.speak(ssml);
let lastCode: TTSMessageCode = 'boundary';
for await (const { code, mark } of iter) {
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
try {
console.log('TTS speak');
this.state = 'playing';
ssml = await ssml;
await this.preloadSSML(ssml);
if (!ssml) {
this.#nossmlCnt++;
// FIXME: in case we are at the end of the book, need a better way to handle this
if (this.#nossmlCnt < 10 && this.state === 'playing') {
await this.view.next(1);
await this.forward();
}
return;
} else {
this.#nossmlCnt = 0;
}
lastCode = code;
}
if (lastCode === 'end' && this.state === 'playing') {
this.forward();
const iter = await this.ttsClient.speak(ssml, signal);
let lastCode: TTSMessageCode = 'boundary';
for await (const { code, mark } of iter) {
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
}
lastCode = code;
}
if (lastCode === 'end' && this.state === 'playing') {
await this.forward();
}
} finally {
this.#currentSpeakAbortController = null;
}
}
async speak(ssml: string | Promise<string>) {
await this.initViewTTS();
this.#speak(ssml).catch((e) => this.error(e));
this.preloadNextSSML(2);
}
play() {
@@ -97,32 +130,34 @@ export class TTSController extends EventTarget {
async start() {
await this.initViewTTS();
const resumeOrStart = this.state.includes('paused')
? this.view.tts?.resume()
: this.view.tts?.start();
return this.#speak(resumeOrStart);
const ssml = this.state.includes('paused') ? this.view.tts?.resume() : this.view.tts?.start();
this.#speak(ssml);
this.preloadNextSSML(2);
}
pause() {
async pause() {
this.state = 'paused';
this.ttsClient.pause().catch((e) => this.error(e));
await this.ttsClient.pause().catch((e) => this.error(e));
}
resume() {
async resume() {
this.state = 'playing';
this.ttsClient.resume().catch((e) => this.error(e));
await this.ttsClient.resume().catch((e) => this.error(e));
}
stop() {
async stop() {
this.state = 'stopped';
this.ttsClient.stop().catch((e) => this.error(e));
if (this.#currentSpeakAbortController) {
this.#currentSpeakAbortController.abort();
}
await this.ttsClient.stop().catch((e) => this.error(e));
}
// goto previous sentence
async backward() {
this.initViewTTS();
await this.initViewTTS();
if (this.state === 'playing') {
this.stop();
await this.stop();
this.#speak(this.view.tts?.prev());
} else {
this.state = 'backward-paused';
@@ -134,8 +169,9 @@ export class TTSController extends EventTarget {
async forward() {
await this.initViewTTS();
if (this.state === 'playing') {
this.stop();
await this.stop();
this.#speak(this.view.tts?.next());
this.preloadNextSSML(2);
} else {
this.state = 'forward-paused';
this.view.tts?.next(true);
@@ -143,7 +179,9 @@ export class TTSController extends EventTarget {
}
async setRate(rate: number) {
this.ttsClient.setRate(rate);
this.state = 'setrate-paused';
this.ttsRate = rate;
await this.ttsClient.setRate(this.ttsRate);
}
async getVoices(lang: string) {
@@ -154,11 +192,15 @@ export class TTSController extends EventTarget {
async setVoice(voiceId: string) {
this.state = 'setvoice-paused';
this.ttsClient.stop();
if (this.ttsEdgeVoices.find((voice) => voice.id === voiceId && !voice.disabled)) {
const useEdgeTTS = !!this.ttsEdgeVoices.find(
(voice) => (voiceId === '' || voice.id === voiceId) && !voice.disabled,
);
if (useEdgeTTS) {
this.ttsClient = this.ttsEdgeClient;
await this.ttsClient.setRate(this.ttsRate);
} else {
this.ttsClient = this.ttsWebClient;
await this.ttsClient.setRate(this.ttsRate);
}
await this.ttsClient.setVoice(voiceId);
}
@@ -172,8 +214,8 @@ export class TTSController extends EventTarget {
this.state = 'stopped';
}
kill() {
async kill() {
this.state = 'stopped';
this.ttsClient.stop();
await this.ttsClient.stop();
}
}
@@ -132,9 +132,9 @@ async function* speakWithMarks(
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance();
for (const mark of marks) {
const utterance = new SpeechSynthesisUtterance(mark.text);
utterance.text = mark.text;
utterance.rate = getRate();
utterance.pitch = getPitch();
const voice = getVoice();
@@ -205,7 +205,14 @@ export class WebSpeechClient implements TTSClient {
return this.available;
}
async *speak(ssml: string): AsyncGenerator<TTSMessageEvent> {
async *speak(
ssml: string,
signal: AbortSignal,
preload = false,
): AsyncGenerator<TTSMessageEvent> {
// no need to preload for web speech
if (preload) return;
const lang = parseSSMLLang(ssml) || 'en';
if (!this.#voice) {
const voices = await this.getVoices(lang);
@@ -218,6 +225,11 @@ export class WebSpeechClient implements TTSClient {
() => this.#pitch,
() => this.#voice,
)) {
if (signal.aborted) {
console.log('TTS aborted');
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
return;
}
if (ev.type === 'boundary') {
yield {
code: 'boundary',
+24 -3
View File
@@ -64,7 +64,10 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
getView: (key: string | null) => (key && get().viewStates[key]?.view) || null,
setView: (key: string, view) =>
set((state) => ({
viewStates: { ...state.viewStates, [key]: { ...state.viewStates[key]!, view } },
viewStates: {
...state.viewStates,
[key]: { ...state.viewStates[key]!, view },
},
})),
getViews: () => Object.values(get().viewStates).map((state) => state.view!),
getViewsById: (id: string) => {
@@ -213,13 +216,31 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const bookData = useBookDataStore.getState().booksData[id];
const viewState = state.viewStates[key];
if (!viewState || !bookData) return state;
const progress: [number, number] = [pageinfo.current, pageinfo.total];
// Update library book progress
const { library, setLibrary } = useLibraryStore.getState();
const bookIndex = library.findIndex((b) => b.hash === id);
if (bookIndex !== -1) {
const updatedLibrary = [...library];
const existingBook = updatedLibrary[bookIndex]!;
updatedLibrary[bookIndex] = {
...existingBook,
progress,
updatedAt: Date.now(),
};
setLibrary(updatedLibrary);
}
const oldConfig = bookData.config;
const newConfig = {
...bookData.config,
updatedAt: Date.now(),
progress: [pageinfo.current, pageinfo.total] as [number, number],
progress,
location,
};
useBookDataStore.setState((state) => ({
booksData: {
...state.booksData,
@@ -229,6 +250,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
},
}));
return {
viewStates: {
...state.viewStates,
@@ -248,7 +270,6 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
};
}),
setBookmarkRibbonVisibility: (key: string, visible: boolean) =>
set((state) => ({
viewStates: {
+1
View File
@@ -19,6 +19,7 @@ export interface Book {
deletedAt?: number | null;
lastUpdated?: number; // deprecated in favor of updatedAt
progress?: [number, number]; // Add progress field: [current, total]
}
export interface PageInfo {
+8
View File
@@ -82,6 +82,14 @@ export const formatTitle = (title: string | LanguageMap) => {
return typeof title === 'string' ? title : formatLanguageMap(title);
};
export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};
export const formatLanguage = (lang: string | string[] | undefined) => {
return Array.isArray(lang) ? lang.join(', ') : lang;
};
export const formatDate = (date: string | number | Date | undefined) => {
if (!date) return;
try {
+36 -7
View File
@@ -1,4 +1,9 @@
import { MONOSPACE_FONTS, SANS_SERIF_FONTS, SERIF_FONTS } from '@/services/constants';
import {
MONOSPACE_FONTS,
SANS_SERIF_FONTS,
SERIF_FONTS,
FALLBACK_FONTS,
} from '@/services/constants';
import { ViewSettings } from '@/types/book';
import fontfacesCSS from '!!raw-loader!../styles/fonts.css';
@@ -11,8 +16,12 @@ const getFontStyles = (
fontSize: number,
overrideFont: boolean,
) => {
const serifFonts = [serif, ...SERIF_FONTS.filter((font) => font !== serif)];
const sansSerifFonts = [sansSerif, ...SANS_SERIF_FONTS.filter((font) => font !== sansSerif)];
const serifFonts = [serif, ...SERIF_FONTS.filter((font) => font !== serif), ...FALLBACK_FONTS];
const sansSerifFonts = [
sansSerif,
...SANS_SERIF_FONTS.filter((font) => font !== sansSerif),
...FALLBACK_FONTS,
];
const monospaceFonts = [monospace, ...MONOSPACE_FONTS.filter((font) => font !== monospace)];
const fontStyles = `
html {
@@ -32,11 +41,15 @@ const getFontStyles = (
return fontStyles;
};
const getAdditionalFontLinks = () => `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/misans-webfont@1.0.4/misans-l3/misans-l3/result.min.css" crossorigin="anonymous">
`;
const getAdditionalFontFaces = () => `
@font-face {
font-family: "FangSong";
font-display: swap;
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
src: local("Fang Song"), local("FangSong"), local("Noto Serif CJK"), local("Source Han Serif SC VF"), url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot");
src: url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.eot?#iefix") format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff2") format("woff2"),
url("https://db.onlinewebfonts.com/t/2ecbfe1d9bfc191c6f15c0ccc23cbd43.woff") format("woff"),
@@ -46,7 +59,7 @@ const getAdditionalFontFaces = () => `
@font-face {
font-family: "Kaiti";
font-display: swap;
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
src: local("Kai"), local("KaiTi"), local("AR PL UKai"), local("LXGW WenKai GB Screen"), url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot");
src: url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/1ee9941f1b8c128110ca4307dda59917.woff")format("woff"),
@@ -56,7 +69,7 @@ const getAdditionalFontFaces = () => `
@font-face {
font-family: "Heiti";
font-display: swap;
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
src: local("Hei"), local("SimHei"), local("WenQuanYi Zen Hei"), local("Source Han Sans SC VF"), url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot");
src: url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/a4948b9d43a91468825a5251df1ec58d.woff")format("woff"),
@@ -66,7 +79,7 @@ const getAdditionalFontFaces = () => `
@font-face {
font-family: "XiHeiti";
font-display: swap;
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
src: local("PingFang SC"), local("Microsoft YaHei"), local("WenQuanYi Micro Hei"), local("FZHei-B01"), url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot");
src: url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.eot?#iefix")format("embedded-opentype"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff2")format("woff2"),
url("https://db.onlinewebfonts.com/t/4f0b783ba4a1b381fc7e7af81ecab481.woff")format("woff"),
@@ -206,6 +219,22 @@ export const getStyles = (viewSettings: ViewSettings, themeCode: ThemeCode) => {
};
export const mountAdditionalFonts = (document: Document) => {
const links = getAdditionalFontLinks();
const parser = new DOMParser();
const parsedDocument = parser.parseFromString(links, 'text/html');
Array.from(parsedDocument.head.children).forEach((child) => {
if (child.tagName === 'LINK') {
const link = document.createElement('link');
link.rel = child.getAttribute('rel') || '';
link.href = child.getAttribute('href') || '';
link.crossOrigin = child.getAttribute('crossorigin') || '';
document.head.appendChild(link);
}
});
const style = document.createElement('style');
style.textContent = getAdditionalFontFaces();
document.head.appendChild(style);
+28
View File
@@ -0,0 +1,28 @@
export const throttle = <T extends (...args: Parameters<T>) => void | Promise<void>>(
func: T,
delay: number,
): ((...args: Parameters<T>) => void) => {
let lastCall = 0;
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>): void => {
const now = Date.now();
const remaining = delay - (now - lastCall);
const callFunc = () => {
lastCall = Date.now();
timeout = null;
func(...args);
};
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
callFunc();
} else if (!timeout) {
timeout = setTimeout(callFunc, remaining);
}
};
};