forked from akai/readest
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a9eb5b57 | |||
| 46fdea3522 | |||
| ba82203629 | |||
| 8dbcf25331 | |||
| 7e9ddcd7cc | |||
| 211fc2cd22 | |||
| 3578d7eba8 | |||
| d220adbfdf | |||
| 752f4449b4 | |||
| 66f369b269 | |||
| 2251d65de4 | |||
| 463ea39467 | |||
| 87bc842525 | |||
| 2ad67a0503 | |||
| 9f894e1d9e |
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]>) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
+1
-1
Submodule packages/foliate-js updated: 3bdbd3d015...b9347d80e8
Reference in New Issue
Block a user