Compare commits

...

14 Commits

Author SHA1 Message Date
Huang Xin 1fe188814d release: version 0.9.51 (#1296) 2025-05-31 18:33:21 +02:00
Huang Xin 6ec3a5b332 feat: add an option to enable JavaScript in EPUB, closes #1278 (#1295) 2025-05-31 18:25:25 +02:00
Huang Xin 1acba2e08c fix: notebook now won't prevent pagination, closes #1285 (#1294) 2025-05-31 15:02:36 +02:00
Huang Xin 32f9346f84 tts: more robust way to save last speaking location for tts (#1293) 2025-05-31 13:34:35 +02:00
Huang Xin 69c503026a bump: catch up foliate-js upstream (#1292) 2025-05-31 13:31:27 +02:00
Huang Xin 84fc4cec5e tts: read from last read sentence in tts (#1291) 2025-05-31 10:53:36 +02:00
Huang Xin 5c9c11fafd translator: disable translator if the system language is the same with the book language (#1290)
Closes #1288
2025-05-31 09:34:52 +02:00
Huang Xin 77fa57438e tts: show speaking sentence and chapter info in tts media session (#1289) 2025-05-31 09:21:17 +02:00
Huang Xin e4d217f3aa config: add theme mode options in the library page (#1283) 2025-05-30 09:10:48 +02:00
Huang Xin c7a583c53a translator: lazy load translation observer (#1282) 2025-05-30 09:08:45 +02:00
Huang Xin 6c86917098 translator: add daily translation quota for deepl (#1275) 2025-05-29 19:24:09 +02:00
Huang Xin 7c21f48d68 layout: hide the header bar only on mobile platforms when translation is enabled (#1274) 2025-05-29 10:31:02 +02:00
Huang Xin 5820191c26 translator: also translate table of contents (#1273) 2025-05-29 10:08:01 +02:00
Huang Xin b37a804192 css: enhanced compatibility with the text-indent CSS property, closes #1267 (#1271) 2025-05-29 08:53:58 +02:00
54 changed files with 489 additions and 116 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.9.50",
"version": "0.9.51",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
@@ -334,5 +334,8 @@
"Scroll": "تمرير",
"Overlap Pixels": "تداخل البكسلات",
"Click": "نقر",
"System Language": "لغة النظام"
"System Language": "لغة النظام",
"Security": "الأمان",
"Allow JavaScript": "السماح بـ JavaScript",
"Enable only if you trust the file.": "تمكين فقط إذا كنت تثق في الملف."
}
@@ -330,5 +330,8 @@
"Scroll": "Scrollen",
"Overlap Pixels": "Überlappungspixel",
"Click": "Tippen",
"System Language": "Systemsprache"
"System Language": "Systemsprache",
"Security": "Sicherheit",
"Allow JavaScript": "JavaScript zulassen",
"Enable only if you trust the file.": "Nur aktivieren, wenn Sie der Datei vertrauen."
}
@@ -330,5 +330,8 @@
"Scroll": "Κύλιση",
"Overlap Pixels": "Επικάλυψη εικονοστοιχείων",
"Click": "Κλικ",
"System Language": "Γλώσσα συστήματος"
"System Language": "Γλώσσα συστήματος",
"Security": "Ασφάλεια",
"Allow JavaScript": "Επιτρέψτε JavaScript",
"Enable only if you trust the file.": "Ενεργοποιήστε μόνο εάν εμπιστεύεστε το αρχείο."
}
@@ -331,5 +331,8 @@
"Scroll": "Desplazar",
"Overlap Pixels": "Superponer píxeles",
"Click": "Click",
"System Language": "Idioma del sistema"
"System Language": "Idioma del sistema",
"Security": "Seguridad",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Permitir solo si confías en el archivo."
}
@@ -331,5 +331,8 @@
"Scroll": "Défilement",
"Overlap Pixels": "Pixels de chevauchement",
"Click": "Cliquer",
"System Language": "Langue du système"
"System Language": "Langue du système",
"Security": "Sécurité",
"Allow JavaScript": "Autoriser JavaScript",
"Enable only if you trust the file.": "Activer uniquement si vous faites confiance au fichier."
}
@@ -330,5 +330,8 @@
"Scroll": "स्क्रॉल",
"Overlap Pixels": "ओवरलैप पिक्सेल",
"Click": "क्लिक करें",
"System Language": "सिस्टम भाषा"
"System Language": "सिस्टम भाषा",
"Security": "सुरक्षा",
"Allow JavaScript": "जावास्क्रिप्ट की अनुमति दें",
"Enable only if you trust the file.": "केवल तभी सक्षम करें जब आप फ़ाइल पर भरोसा करते हों।"
}
@@ -329,5 +329,8 @@
"Scroll": "Scroll",
"Overlap Pixels": "Overlap Piksel",
"Click": "Klik",
"System Language": "Bahasa Sistem"
"System Language": "Bahasa Sistem",
"Security": "Keamanan",
"Allow JavaScript": "Izinkan JavaScript",
"Enable only if you trust the file.": "Aktifkan hanya jika Anda mempercayai file tersebut."
}
@@ -331,5 +331,8 @@
"Scroll": "Scorri",
"Overlap Pixels": "Pixel di sovrapposizione",
"Click": "Fai clic",
"System Language": "Lingua di sistema"
"System Language": "Lingua di sistema",
"Security": "Security",
"Allow JavaScript": "Consenti JavaScript",
"Enable only if you trust the file.": "Abilita solo se ti fidi del file."
}
@@ -329,5 +329,8 @@
"Scroll": "スクロール",
"Overlap Pixels": "重なりピクセル",
"Click": "クリック",
"System Language": "システム言語"
"System Language": "システム言語",
"Security": "セキュリティ",
"Allow JavaScript": "JavaScriptを許可",
"Enable only if you trust the file.": "ファイルを信頼する場合にのみ有効にしてください。"
}
@@ -329,5 +329,8 @@
"Scroll": "스크롤",
"Overlap Pixels": "겹치는 픽셀",
"Click": "클릭",
"System Language": "시스템 언어"
"System Language": "시스템 언어",
"Security": "보안",
"Allow JavaScript": "JavaScript 허용",
"Enable only if you trust the file.": "파일을 신뢰하는 경우에만 활성화하세요."
}
@@ -330,5 +330,8 @@
"Scroll": "Scrollen",
"Overlap Pixels": "Overlap pixels",
"Click": "Klik",
"System Language": "Systemtaal"
"System Language": "Systemtaal",
"Security": "Beveiliging",
"Allow JavaScript": "JavaScript toestaan",
"Enable only if you trust the file.": "Schakel alleen in als u het bestand vertrouwt."
}
@@ -332,5 +332,8 @@
"Scroll": "Przewijaj",
"Overlap Pixels": "Przesunięcie pikseli",
"Click": "Kliknij",
"System Language": "Język systemu"
"System Language": "Język systemu",
"Security": "Bezpieczeństwo",
"Allow JavaScript": "Zezwól na JavaScript",
"Enable only if you trust the file.": "Zezwól tylko wtedy, gdy ufasz plikowi."
}
@@ -331,5 +331,8 @@
"Scroll": "Rolar",
"Overlap Pixels": "Sobreposição de Pixels",
"Click": "Clique",
"System Language": "Idioma do Sistema"
"System Language": "Idioma do Sistema",
"Security": "Segurança",
"Allow JavaScript": "Permitir JavaScript",
"Enable only if you trust the file.": "Ativar apenas se você confiar no arquivo."
}
@@ -332,5 +332,8 @@
"Scroll": "Прокрутка",
"Overlap Pixels": "Перекрывающиеся пиксели",
"Click": "Клик",
"System Language": "Системный язык"
"System Language": "Системный язык",
"Security": "Безопасность",
"Allow JavaScript": "Разрешить JavaScript",
"Enable only if you trust the file.": "Включите только если доверяете файлу."
}
@@ -330,5 +330,8 @@
"Scroll": "Kaydır",
"Overlap Pixels": "Örtüşen Pikseller",
"Click": "Klik",
"System Language": "System Dili"
"System Language": "System Dili",
"Security": "Security",
"Allow JavaScript": "JavaScript'e İzin Ver",
"Enable only if you trust the file.": "Yalnızca dosyaya güveniyorsanız etkinleştirin."
}
@@ -332,5 +332,8 @@
"Scroll": "Прокрутка",
"Overlap Pixels": "Пікселі перекриття",
"Click": "Клацнути",
"System Language": "Системна мова"
"System Language": "Системна мова",
"Security": "Безпека",
"Allow JavaScript": "Дозволити JavaScript",
"Enable only if you trust the file.": "Увімкніть лише якщо довіряєте файлу."
}
@@ -329,5 +329,8 @@
"Scroll": "Cuộn",
"Overlap Pixels": "Chồng lấn pixel",
"Click": "Nhấp",
"System Language": "Ngôn ngữ hệ thống"
"System Language": "Ngôn ngữ hệ thống",
"Security": "An ninh",
"Allow JavaScript": "Cho phép JavaScript",
"Enable only if you trust the file.": "Chỉ bật nếu bạn tin tưởng tệp."
}
@@ -329,5 +329,8 @@
"Scroll": "滚动",
"Overlap Pixels": "重叠像素",
"Click": "点击",
"System Language": "系统语言"
"System Language": "系统语言",
"Security": "安全",
"Allow JavaScript": "允许 JavaScript",
"Enable only if you trust the file.": "建議僅在您信任該檔案時啟用"
}
@@ -329,5 +329,8 @@
"Scroll": "滾動",
"Overlap Pixels": "重疊像素",
"Click": "點擊",
"System Language": "系統語言"
"System Language": "系統語言",
"Security": "安全",
"Allow JavaScript": "允許 JavaScript",
"Enable only if you trust the file.": "僅在您信任該文件時啟用"
}
+11
View File
@@ -1,5 +1,16 @@
{
"releases": {
"0.9.51": {
"date": "2025-06-01",
"notes": [
"Translator: Table of contents is now included in translations",
"Library Theme: Added light, dark, and system theme options to the library page",
"TTS: Now shows the current sentence and chapter in the media session",
"TTS: Text-to-speech continues from the last read sentence",
"Pagination Fix: Taking notes no longer breaks page turning",
"EPUB Scripts: Added a setting to allow JavaScript in EPUB files"
]
},
"0.9.50": {
"date": "2025-05-28",
"notes": [
@@ -4,12 +4,15 @@ import { useRouter } from 'next/navigation';
import { PiUserCircle } from 'react-icons/pi';
import { PiUserCircleCheck } from 'react-icons/pi';
import { MdCheck } from 'react-icons/md';
import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
@@ -31,6 +34,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { token, user } = useAuth();
const { themeMode, setThemeMode } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const [quotas, setQuotas] = React.useState<QuotaType[]>([]);
const [isAutoUpload, setIsAutoUpload] = useState(settings.autoUpload);
@@ -48,6 +52,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setAboutDialogVisible(true);
setIsDropdownOpen?.(false);
};
const downloadReadest = () => {
window.open(DOWNLOAD_READEST_URL, '_blank');
setIsDropdownOpen?.(false);
@@ -63,6 +68,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
const cycleThemeMode = () => {
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
const handleReloadPage = () => {
window.location.reload();
setIsDropdownOpen?.(false);
@@ -226,6 +236,17 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
onClick={toggleScreenWakeLock}
/>
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<MenuItem
label={
themeMode === 'dark'
? _('Dark Mode')
: themeMode === 'light'
? _('Light Mode')
: _('Auto Mode')
}
Icon={themeMode === 'dark' ? BiMoon : themeMode === 'light' ? BiSun : TbSunMoon}
onClick={cycleThemeMode}
/>
<hr className='border-base-200 my-1' />
{isWebAppPlatform() && <MenuItem label={_('Download Readest')} onClick={downloadReadest} />}
<MenuItem label={_('About Readest')} onClick={showAboutReadest} />
@@ -41,8 +41,8 @@ const FooterBar: React.FC<FooterBarProps> = ({
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { hoveredBookKey, setHoveredBookKey, getView, getProgress, getViewSettings } =
useReaderStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
@@ -53,6 +53,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
@@ -108,11 +109,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
const handleSpeakText = async () => {
if (!view || !progress) return;
const { range } = progress;
if (eventDispatcher.dispatchSync('tts-is-speaking')) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey, range });
eventDispatcher.dispatch('tts-speak', { bookKey });
}
};
@@ -147,6 +147,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
@@ -312,7 +313,10 @@ const FooterBar: React.FC<FooterBarProps> = ({
}
onClick={() => handleSetActionTab('font')}
/>
<Button icon={<TTSIcon className='' />} onClick={() => handleSetActionTab('tts')} />
<Button
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop footer bar */}
<div className='hidden w-full items-center gap-x-4 px-4 sm:flex'>
@@ -351,7 +355,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button icon={<FaHeadphones />} onClick={handleSpeakText} tooltip={_('Speak')} />
<Button
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
tooltip={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
@@ -120,6 +120,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<SidebarToggler bookKey={bookKey} />
</div>
<BookmarkToggler bookKey={bookKey} />
<TranslationToggler bookKey={bookKey} />
</div>
<div className='header-title z-15 bg-base-100 pointer-events-none absolute inset-0 hidden items-center justify-center sm:flex'>
@@ -129,7 +130,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
</div>
<div className='bg-base-100 z-20 ml-auto flex h-full items-center space-x-4'>
<TranslationToggler bookKey={bookKey} />
<SettingsToggler />
<NotebookToggler bookKey={bookKey} />
<Dropdown
@@ -8,10 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { saveViewSettings } from '../utils/viewSettingsHelper';
import { isSameLang } from '@/utils/lang';
import Button from '@/components/Button';
import { getLocale } from '@/utils/misc';
const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getViewSettings, setViewSettings, setHoveredBookKey } = useReaderStore();
@@ -19,9 +20,14 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
const viewSettings = getViewSettings(bookKey)!;
const [translationEnabled, setTranslationEnabled] = useState(viewSettings.translationEnabled!);
const primaryLanguage = bookData?.book?.primaryLanguage;
const targetLanguage = viewSettings.translateTargetLang;
useEffect(() => {
if (translationEnabled === viewSettings.translationEnabled) return;
setHoveredBookKey('');
if (appService?.isMobile) {
setHoveredBookKey('');
}
saveViewSettings(envConfig, bookKey, 'translationEnabled', translationEnabled, true, false);
viewSettings.translationEnabled = translationEnabled;
setViewSettings(bookKey, { ...viewSettings });
@@ -34,7 +40,10 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
}
disabled={
!bookData || isSameLang(bookData.book?.primaryLanguage, viewSettings.translateTargetLang!)
!bookData ||
bookData.book?.format === 'PDF' ||
isSameLang(primaryLanguage, targetLanguage) ||
(!targetLanguage && isSameLang(primaryLanguage, getLocale()))
}
onClick={() => setTranslationEnabled(!translationEnabled)}
tooltip={translationEnabled ? _('Disable Translation') : _('Enable Translation')}
@@ -10,7 +10,6 @@ import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { ThemeMode } from '@/styles/themes';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { tauriHandleToggleFullScreen } from '@/utils/window';
@@ -51,8 +50,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({
};
const cycleThemeMode = () => {
const nextMode: ThemeMode =
themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
@@ -91,8 +91,15 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
view?.deselect();
};
const { handleScroll, handlePointerup, handleSelectionchange, handleShowPopup, handleUpToPopup } =
useTextSelector(bookKey, setSelection, handleDismissPopup);
const {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerup,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
} = useTextSelector(bookKey, setSelection, handleDismissPopup);
const onLoad = (event: Event) => {
const detail = (event as CustomEvent).detail;
@@ -105,7 +112,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
};
if (bookData.book?.format !== 'PDF') {
view?.renderer?.addEventListener('scroll', handleScroll);
detail.doc?.addEventListener('touchstart', handleTouchStart);
detail.doc?.addEventListener('touchmove', handleTouchmove);
detail.doc?.addEventListener('touchend', handleTouchEnd);
detail.doc?.addEventListener('pointerup', () => handlePointerup(doc, index));
detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc, index));
@@ -98,7 +98,7 @@ const TranslatorPopup: React.FC<TranslatorPopupProps> = ({
setTranslation(null);
try {
const input = text.replaceAll('\n', ' ').trim();
const input = text.replaceAll('\n', '').trim();
const result = await translate([input]);
const translatedText = result[0];
const detectedSource = null;
@@ -3,6 +3,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { getStyles } from '@/utils/style';
import { getMaxInlineSize } from '@/utils/config';
import { saveViewSettings } from '../../utils/viewSettingsHelper';
@@ -12,7 +13,9 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const bookData = getBookData(bookKey)!;
const viewSettings = getViewSettings(bookKey)!;
const [isScrolledMode, setScrolledMode] = useState(viewSettings.scrolled!);
@@ -22,6 +25,7 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [isDisableClick, setIsDisableClick] = useState(viewSettings.disableClick!);
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea!);
const [animated, setAnimated] = useState(viewSettings.animated!);
const [allowScript, setAllowScript] = useState(viewSettings.allowScript!);
useEffect(() => {
if (isScrolledMode === viewSettings.scrolled) return;
@@ -78,6 +82,13 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
setTimeout(() => window.location.reload(), 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowScript]);
return (
<div className='my-4 w-full space-y-6'>
<div className='w-full'>
@@ -169,6 +180,27 @@ const ControlPanel: React.FC<{ bookKey: string }> = ({ bookKey }) => {
</div>
</div>
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Security')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item !h-16'>
<div className='flex flex-col gap-1'>
<span className=''>{_('Allow JavaScript')}</span>
<span className='text-xs'>{_('Enable only if you trust the file.')}</span>
</div>
<input
type='checkbox'
className='toggle'
checked={allowScript}
disabled={bookData.book?.format !== 'EPUB'}
onChange={() => setAllowScript(!allowScript)}
/>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -8,6 +8,7 @@ import { findParentPath } from '@/utils/toc';
import { getContentMd5 } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { BookProgress } from '@/types/book';
import { useTextTranslation } from '../../hooks/useTextTranslation';
const createExpanderIcon = (isExpanded: boolean) => {
return (
@@ -129,6 +130,8 @@ const TOCView: React.FC<{
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const viewRef = useRef<HTMLUListElement | null>(null);
useTextTranslation(bookKey, viewRef.current);
const expandParents = (toc: TOCItem[], href: string) => {
const parentPath = findParentPath(toc, href).map((item) => item.href);
setExpandedItems(parentPath.filter(Boolean) as string[]);
@@ -5,13 +5,14 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA } from '@/services/tts';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -24,7 +25,8 @@ const TTSControl = () => {
const _ = useTranslation();
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const { getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
const [bookKey, setBookKey] = useState<string>('');
const [ttsLang, setTtsLang] = useState<string>('en');
const [isPlaying, setIsPlaying] = useState(false);
@@ -45,8 +47,10 @@ const TTSControl = () => {
const popupHeight = useResponsiveSize(POPUP_HEIGHT);
const iconRef = useRef<HTMLDivElement>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
// this enables WebAudio to play even when the mute toggle switch is ON
const unblockAudio = () => {
@@ -95,12 +99,58 @@ const TTSControl = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!ttsController || !bookKey) return;
const bookData = getBookData(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const handleSpeakMark = (e: Event) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if (appService?.isMobileApp && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark.text,
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
};
const handleHighlightMark = (e: Event) => {
const range = (e as CustomEvent<Range>).detail;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
if (!range || !view || !progress || !viewSettings) return;
const { location } = progress;
const { index } = view.resolveCFI(location);
const cfi = view?.getCFI(index, range);
if (cfi) {
viewSettings.ttsLocation = cfi || '';
setViewSettings(bookKey, viewSettings);
}
};
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
ttsController.addEventListener('tts-highlight-mark', handleHighlightMark);
return () => {
ttsController.removeEventListener('tts-speak-mark', handleSpeakMark);
ttsController.removeEventListener('tts-highlight-mark', handleHighlightMark);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey, range } = event.detail;
const view = getView(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (!view || !viewSettings || !bookData || !bookData.book) return;
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
@@ -109,6 +159,21 @@ const TTSControl = () => {
return;
}
let ttsFromRange = range || progress.range;
if (viewSettings.ttsLocation) {
const { location } = progress;
const ttsCfi = viewSettings.ttsLocation;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
if (CFI.compare(start, ttsCfi) * CFI.compare(end, ttsCfi) <= 0) {
const { index, anchor } = view.resolveCFI(ttsCfi);
const { doc } = view.renderer.getContents().find((x) => (x.index = index)) || {};
if (doc) {
ttsFromRange = anchor(doc) || ttsFromRange;
}
}
}
const primaryLang = bookData.book.primaryLanguage;
setBookKey(bookKey);
@@ -116,6 +181,8 @@ const TTSControl = () => {
ttsControllerRef.current.stop();
ttsControllerRef.current = null;
}
setTTSEnabled(bookKey, true);
setShowIndicator(true);
try {
@@ -125,10 +192,11 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
unblockAudio();
}
setTtsClientsInitialized(false);
const ttsController = new TTSController(view);
await ttsController.init();
await ttsController.initViewTTS();
const ssml = view.tts?.from(range);
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
@@ -142,7 +210,9 @@ const TTSControl = () => {
ttsController.setRate(viewSettings.ttsRate);
ttsController.speak(ssml);
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
}
setTtsClientsInitialized(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported in this device'),
@@ -152,9 +222,10 @@ const TTSControl = () => {
}
};
const handleTTSStop = async () => {
const handleTTSStop = async (event: CustomEvent) => {
const { bookKey } = event.detail;
if (ttsControllerRef.current) {
handleStop();
handleStop(bookKey);
}
};
@@ -197,11 +268,12 @@ const TTSControl = () => {
}
};
const handleStop = async () => {
const handleStop = async (bookKey: string) => {
const ttsController = ttsControllerRef.current;
if (ttsController) {
await ttsController.stop();
ttsControllerRef.current = null;
setTtsController(null);
getView(bookKey)?.deselect();
setIsPlaying(false);
setShowPanel(false);
@@ -213,6 +285,7 @@ const TTSControl = () => {
if (getOSPlatform() === 'ios' || appService?.isIOSApp) {
releaseUnblockAudio();
}
setTTSEnabled(bookKey, false);
};
// rate range: 0.5 - 3, 1.0 is normal speed
@@ -266,7 +339,7 @@ const TTSControl = () => {
return '';
};
const handleSelectTimeout = (value: number) => {
const handleSelectTimeout = (bookKey: string, value: number) => {
setTimeoutOption(value);
if (timeoutFunc) {
clearTimeout(timeoutFunc);
@@ -274,7 +347,7 @@ const TTSControl = () => {
if (value > 0) {
setTimeoutFunc(
setTimeout(() => {
handleStop();
handleStop(bookKey);
}, value * 1000),
);
setTimeoutTimestamp(Date.now() + value * 1000);
@@ -356,10 +429,10 @@ const TTSControl = () => {
: 'bottom-[70px] sm:bottom-14',
)}
>
<TTSIcon isPlaying={isPlaying} onClick={togglePopup} />
<TTSIcon isPlaying={isPlaying} ttsInited={ttsClientsInited} onClick={togglePopup} />
</div>
)}
{showPanel && panelPosition && trianglePosition && (
{showPanel && panelPosition && trianglePosition && ttsClientsInited && (
<Popup
width={popupWidth}
height={popupHeight}
@@ -1,20 +1,28 @@
import clsx from 'clsx';
import React from 'react';
type TTSIconProps = {
isPlaying: boolean;
ttsInited: boolean;
onClick: () => void;
};
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, onClick }) => {
const TTSIcon: React.FC<TTSIconProps> = ({ isPlaying, ttsInited, onClick }) => {
const bars = [1, 2, 3, 4];
return (
<div className='relative h-full w-full cursor-pointer' onClick={onClick}>
<div
className={clsx(
'relative h-full w-full',
ttsInited ? 'cursor-pointer' : 'cursor-not-allowed',
)}
onClick={onClick}
>
<div className='absolute inset-0 overflow-hidden rounded-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'>
<div
className='absolute -inset-full bg-gradient-to-r from-blue-500 via-emerald-500 to-violet-500'
style={{
animation: isPlaying ? 'moveGradient 2s alternate infinite' : 'none',
animation: isPlaying && ttsInited ? 'moveGradient 2s alternate infinite' : 'none',
}}
/>
</div>
@@ -23,7 +23,7 @@ type TTSPanelProps = {
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string, lang: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
onSelectTimeout: (bookKey: string, value: number) => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
@@ -155,7 +155,7 @@ const TTSPanel = ({
const updateTimeout = (timeout: number) => {
const now = Date.now();
if (timeout > 0 && timeout < now) {
onSelectTimeout(0);
onSelectTimeout(bookKey, 0);
setTimeoutCountdown('');
} else if (timeout > 0) {
setTimeoutCountdown(getCountdownTime(timeout));
@@ -256,7 +256,10 @@ const TTSPanel = ({
)}
>
{timeoutOptions.map((option, index) => (
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
<li
key={`${index}-${option.value}`}
onClick={() => onSelectTimeout(bookKey, option.value)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
@@ -14,7 +14,6 @@ export const useTextSelector = (
const { getBookData } = useBookDataStore();
const { getView, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
const primaryLang = bookData.book?.primaryLanguage || 'en';
const osPlatform = getOSPlatform();
@@ -22,7 +21,9 @@ export const useTextSelector = (
const isPopuped = useRef(false);
const isUpToPopup = useRef(false);
const isTextSelected = useRef(false);
const isTouchStarted = useRef(false);
const selectionPosition = useRef<number | null>(null);
const [textSelected, setTextSelected] = useState(false);
const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
@@ -41,6 +42,7 @@ export const useTextSelector = (
};
const makeSelection = async (sel: Selection, index: number, rebuildRange = false) => {
isTextSelected.current = true;
setTextSelected(true);
const range = sel.getRangeAt(0);
if (rebuildRange) {
sel.removeAllRanges();
@@ -51,6 +53,7 @@ export const useTextSelector = (
// FIXME: extremely hacky way to dismiss system selection tools on iOS
const makeSelectionOnIOS = async (sel: Selection, index: number) => {
isTextSelected.current = true;
setTextSelected(true);
const range = sel.getRangeAt(0);
setTimeout(() => {
sel.removeAllRanges();
@@ -70,6 +73,7 @@ export const useTextSelector = (
if (!isUpToPopup.current) {
handleDismissPopup();
isTextSelected.current = false;
setTextSelected(false);
}
if (isPopuped.current) {
isUpToPopup.current = false;
@@ -79,7 +83,8 @@ export const useTextSelector = (
// On Android no proper events are fired to notify selection done,
// we make the popup show when the selection is changed
if (osPlatform === 'android') {
// note that selection may be initiated by a tts speak
if (isTouchStarted.current && osPlatform === 'android') {
makeSelection(sel, index, false);
}
isUpToPopup.current = true;
@@ -96,11 +101,19 @@ export const useTextSelector = (
}
}
};
const handleTouchStart = () => {
isTouchStarted.current = true;
};
const handleTouchEnd = () => {
isTouchStarted.current = false;
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// This is a workaround for the issue #873
// TODO: support text selection across pages
const viewSettings = getViewSettings(bookKey);
if (!viewSettings?.scrolled && view?.renderer?.containerPosition && selectionPosition.current) {
console.warn('Keep container position', selectionPosition.current);
view.renderer.containerPosition = selectionPosition.current;
}
};
@@ -128,7 +141,7 @@ export const useTextSelector = (
selectionPosition.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTextSelected.current]);
}, [textSelected]);
useEffect(() => {
const handleSingleClick = (): boolean => {
@@ -139,6 +152,7 @@ export const useTextSelector = (
if (isTextSelected.current) {
handleDismissPopup();
isTextSelected.current = false;
setTextSelected(false);
view?.deselect();
return true;
}
@@ -158,6 +172,8 @@ export const useTextSelector = (
return {
handleScroll,
handleTouchStart,
handleTouchEnd,
handlePointerup,
handleSelectionchange,
handleShowPopup,
@@ -7,13 +7,13 @@ import { walkTextNodes } from '@/utils/walk';
import { localeToLang } from '@/utils/lang';
import { getLocale } from '@/utils/misc';
export function useTextTranslation(bookKey: string, view: FoliateView | null) {
export function useTextTranslation(bookKey: string, view: FoliateView | HTMLElement | null) {
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey)!;
const viewSettings = getViewSettings(bookKey);
const enabled = useRef(viewSettings.translationEnabled);
const [provider, setProvider] = useState(viewSettings.translationProvider);
const [targetLang, setTargetLang] = useState(viewSettings.translateTargetLang);
const enabled = useRef(viewSettings?.translationEnabled);
const [provider, setProvider] = useState(viewSettings?.translationProvider);
const [targetLang, setTargetLang] = useState(viewSettings?.translateTargetLang);
const { translate } = useTranslator({
provider,
@@ -42,6 +42,16 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
translateRef.current = translate;
}, [translate]);
const observeTextNodes = () => {
if (!view || !enabled.current) return;
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
console.log('Observing text nodes for translation:', nodes.length);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
};
const updateTranslation = () => {
translatedElements.current.forEach((element) => {
const translationTargets = element.querySelectorAll('.translation-target');
@@ -49,7 +59,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
});
translatedElements.current = [];
if (viewSettings.translationEnabled && view) {
if (viewSettings?.translationEnabled && view) {
recreateTranslationObserver();
}
};
@@ -136,6 +146,9 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
blockWrapper.appendChild(inner);
wrapper.appendChild(blockWrapper);
if (el.querySelector('.translation-target')) {
return;
}
el.appendChild(wrapper);
translatedElements.current.push(el);
} catch (err) {
@@ -165,7 +178,7 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
if (enabledChanged) {
toggleTranslationVisibility(viewSettings.translationEnabled);
if (enabled.current) {
recreateTranslationObserver();
observeTextNodes();
}
} else if (providerChanged || targetLangChanged) {
updateTranslation();
@@ -174,28 +187,20 @@ export function useTextTranslation(bookKey: string, view: FoliateView | null) {
}, [bookKey, viewSettings, provider, targetLang]);
useEffect(() => {
if (!view) return;
if (!view || !enabled.current) return;
const onLoad = () => {
const observer = createTranslationObserver();
observerRef.current = observer;
const nodes = walkTextNodes(view);
allTextNodes.current = nodes;
nodes.forEach((el) => observer.observe(el));
};
view.addEventListener('load', onLoad);
return () => {
view.removeEventListener('load', onLoad);
observerRef.current?.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
useEffect(() => {
if ('renderer' in view) {
view.addEventListener('load', observeTextNodes);
} else {
observeTextNodes();
}
return () => {
if ('renderer' in view) {
view.removeEventListener('load', observeTextNodes);
}
observerRef.current?.disconnect();
translatedElements.current = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function useTranslator({
),
);
return results;
return enablePolishing ? polish(results, targetLanguage) : results;
}
setLoading(true);
+2 -2
View File
@@ -178,7 +178,7 @@ export class DocumentLoader {
);
}
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
public async open({ allowScript = false } = {}): Promise<{ book: BookDoc; format: BookFormat }> {
let book = null;
let format: BookFormat = 'EPUB';
if (!this.file.size) {
@@ -200,7 +200,7 @@ export class DocumentLoader {
format = 'FBZ';
} else {
const { EPUB } = await import('foliate-js/epub.js');
book = await new EPUB(loader).init();
book = await new EPUB(loader).init({ allowScript });
format = 'EPUB';
}
} else if (await this.isPDF()) {
@@ -2,11 +2,18 @@ import crypto from 'crypto';
import { NextApiRequest, NextApiResponse } from 'next';
import { corsAllMethods, runMiddleware } from '@/utils/cors';
import { supabase } from '@/utils/supabase';
import { getUserPlan } from '@/utils/access';
import { getDailyTranslationPlanData, getUserPlan } from '@/utils/access';
const DEFAULT_DEEPL_FREE_API = 'https://api-free.deepl.com/v2/translate';
const DEFAULT_DEEPL_PRO_API = 'https://api.deepl.com/v2/translate';
const ErrorCodes = {
UNAUTHORIZED: 'Unauthorized',
DEEPL_API_ERROR: 'DeepL API Error',
DAILY_QUOTA_EXCEEDED: 'Daily Quota Exceeded',
INTERNAL_SERVER_ERROR: 'Internal Server Error',
};
interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
@@ -101,7 +108,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
}
}
// if (!user || !token) return res.status(401).json({ error: ErrorCodes.UNAUTHORIZED });
return await callDeepLAPI(
user?.id,
token,
singleText,
sourceLang,
targetLang,
@@ -115,11 +126,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(200).json({ translations });
} catch (error) {
console.error('Error proxying DeepL request:', error);
return res.status(500).json({ error: 'Internal Server Error' });
if (error instanceof Error && error.message.includes(ErrorCodes.DAILY_QUOTA_EXCEEDED)) {
return res.status(429).json({ error: ErrorCodes.DAILY_QUOTA_EXCEEDED });
}
return res.status(500).json({ error: ErrorCodes.INTERNAL_SERVER_ERROR });
}
};
async function callDeepLAPI(
userId: string | undefined,
token: string | undefined,
text: string,
sourceLang: string,
targetLang: string,
@@ -128,11 +144,22 @@ async function callDeepLAPI(
translationsKV: KVNamespace | undefined,
useCache: boolean,
) {
let dailyUsageKey = '';
if (userId && token) {
const { quota: dailyQuota } = getDailyTranslationPlanData(token);
const currentDate = new Date().toISOString().split('T')[0]!;
dailyUsageKey = `daily_usage:${currentDate}:${userId}`;
const dailyUsage = (await translationsKV?.get(dailyUsageKey)) || '0';
if (dailyQuota <= parseInt(dailyUsage) + text.length) {
throw new Error(ErrorCodes.DAILY_QUOTA_EXCEEDED);
}
}
const isV2Api = apiUrl.endsWith('/v2/translate');
// TODO: this should be processed in the client, but for now, we need to do it here
// please remove this when most clients are updated
const input = text.replaceAll('\n', ' ').trim();
const input = text.replaceAll('\n', '').trim();
const requestBody = {
text: isV2Api ? [input] : input,
source_lang: isV2Api ? sourceLang : (LANG_V2_V1_MAP[sourceLang] ?? sourceLang),
@@ -166,6 +193,20 @@ async function callDeepLAPI(
translatedText = data.data;
}
let newDailyUsage = 0;
if (dailyUsageKey && translationsKV) {
try {
const usage = translatedText.length + text.length;
const dailyUsage = (await translationsKV.get(dailyUsageKey)) || '0';
newDailyUsage = parseInt(dailyUsage) + usage;
await translationsKV.put(dailyUsageKey, newDailyUsage.toString(), {
expirationTtl: 86400 * 30,
});
} catch (cacheError) {
console.error('Cache storage error:', cacheError);
}
}
if (useCache && translationsKV && translatedText) {
try {
const cacheKey = generateCacheKey(text, sourceLang, targetLang);
@@ -177,6 +218,7 @@ async function callDeepLAPI(
return {
text: translatedText,
daily_usage: newDailyUsage,
detected_source_language: detectedSourceLanguage,
};
}
+1 -1
View File
@@ -401,7 +401,7 @@ export abstract class BaseAppService implements AppService {
}
} catch (error) {
// don't throw error here since some books may not have cover images at all
console.log('Failed to download cover file:', error);
console.log(`Failed to download cover file for book: '${book.title}'`, error);
}
if (needDownBook) {
+9 -1
View File
@@ -11,7 +11,7 @@ import {
ViewSettings,
} from '@/types/book';
import { ReadSettings, SystemSettings } from '@/types/settings';
import { UserStorageQuota } from '@/types/user';
import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/user';
import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config';
import { stubTranslation as _ } from '@/utils/misc';
@@ -109,6 +109,7 @@ export const DEFAULT_BOOK_LAYOUT: BookLayout = {
showFooter: true,
showBarsOnScroll: false,
scrollingOverlap: 0,
allowScript: false,
};
export const DEFAULT_BOOK_STYLE: BookStyle = {
@@ -146,6 +147,7 @@ export const DEFAULT_VIEW_CONFIG: ViewConfig = {
export const DEFAULT_TTS_CONFIG: TTSConfig = {
ttsRate: 1.3,
ttsVoice: '',
ttsLocation: '',
};
export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
@@ -527,6 +529,12 @@ export const DEFAULT_STORAGE_QUOTA: UserStorageQuota = {
pro: 10 * 1024 * 1024 * 1024,
};
export const DEFAULT_DAILY_TRANSLATION_QUOTA: UserDailyTranslationQuota = {
free: 100 * 1024,
plus: 1 * 1024 * 1024,
pro: 10 * 1024 * 1024,
};
export const DOUBLE_CLICK_INTERVAL_THRESHOLD_MS = 250;
export const DISABLE_DOUBLE_CLICK_ON_MOBILE = true;
export const LONG_HOLD_THRESHOLD = 500;
@@ -3,9 +3,11 @@ import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
export class EdgeTTSClient implements TTSClient {
controller?: TTSController;
#primaryLang = 'en';
#speakingLang = '';
#rate = 1.0;
@@ -20,7 +22,8 @@ export class EdgeTTSClient implements TTSClient {
#startedAt = 0;
available = true;
constructor() {
constructor(controller?: TTSController) {
this.controller = controller;
this.#edgeTTS = new EdgeSpeechTTS();
}
@@ -129,6 +132,8 @@ export class EdgeTTSClient implements TTSClient {
audio.setAttribute('x-webkit-airplay', 'deny');
audio.preload = 'auto';
this.controller?.dispatchSpeakMark(mark);
yield {
code: 'boundary',
message: `Start chunk: ${mark.name}`,
@@ -4,6 +4,7 @@ import { parseSSMLMarks } from '@/utils/ssml';
import { WebSpeechClient } from './WebSpeechClient';
import { EdgeTTSClient } from './EdgeTTSClient';
import { TTSUtils } from './TTSUtils';
import { TTSMark } from './types';
type TTSState =
| 'stopped'
@@ -31,8 +32,8 @@ export class TTSController extends EventTarget {
constructor(view: FoliateView) {
super();
this.ttsWebClient = new WebSpeechClient();
this.ttsEdgeClient = new EdgeTTSClient();
this.ttsWebClient = new WebSpeechClient(this);
this.ttsEdgeClient = new EdgeTTSClient(this);
this.ttsClient = this.ttsWebClient;
this.view = view;
}
@@ -86,6 +87,7 @@ export class TTSController extends EventTarget {
.replace(/[–—]/g, ',')
.replace('<break/>', ' ')
.replace(/\.{3,}/g, ' ')
.replace(/……/g, ' ')
.replace(/·/g, ' ');
return ssml;
@@ -128,7 +130,8 @@ export class TTSController extends EventTarget {
return;
}
if (mark && this.state === 'playing') {
this.view.tts?.setMark(mark);
const range = this.view.tts?.setMark(mark);
this.dispatchEvent(new CustomEvent('tts-highlight-mark', { detail: range }));
}
lastCode = code;
}
@@ -272,6 +275,10 @@ export class TTSController extends EventTarget {
return this.ttsClient.getSpeakingLang();
}
dispatchSpeakMark(mark: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark }));
}
error(e: unknown) {
console.error(e);
this.state = 'stopped';
@@ -2,7 +2,9 @@ import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent, TTSVoice } from './TTSClient';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity } from '@/types/view';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
import { TTSMark } from './types';
const BLACKLISTED_VOICES = [
'Albert',
@@ -50,7 +52,8 @@ async function* speakWithMarks(
getRate: () => number,
getPitch: () => number,
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
setSpeakingLang: (lang: string) => void = () => {},
setSpeakingLang: (lang: string) => void,
dispatchSpeakMark: (mark: TTSMark) => void,
) {
const { marks } = parseSSMLMarks(ssml);
@@ -61,6 +64,7 @@ async function* speakWithMarks(
const { language } = mark;
const voiceLang = language || defaultLang;
setSpeakingLang(voiceLang);
dispatchSpeakMark(mark);
utterance.text = mark.text;
utterance.rate = getRate();
utterance.pitch = getPitch();
@@ -99,6 +103,7 @@ async function* speakWithMarks(
}
export class WebSpeechClient implements TTSClient {
controller?: TTSController;
#primaryLang = 'en';
#speakingLang = '';
#rate = 1.0;
@@ -108,6 +113,10 @@ export class WebSpeechClient implements TTSClient {
#synth = window.speechSynthesis;
available = true;
constructor(controller?: TTSController) {
this.controller = controller;
}
async init() {
if (!this.#synth) {
this.available = false;
@@ -166,6 +175,7 @@ export class WebSpeechClient implements TTSClient {
() => this.#pitch,
this.getVoiceFromLang,
(lang) => (this.#speakingLang = lang),
(mark) => this.controller?.dispatchSpeakMark(mark),
)) {
if (signal.aborted) {
console.log('TTS aborted');
@@ -1,3 +1,4 @@
export * from './types';
export * from './TTSClient';
export * from './WebSpeechClient';
export * from './EdgeTTSClient';
@@ -0,0 +1,6 @@
export type TTSMark = {
offset: number;
name: string;
text: string;
language?: string;
};
+19 -1
View File
@@ -19,6 +19,7 @@ interface ViewState {
error: string | null;
progress: BookProgress | null;
ribbonVisible: boolean;
ttsEnabled: boolean;
/* View settings for the view:
generally view settings have a hierarchy of global settings < book settings < view settings
view settings for primary view are saved to book config which is persisted to config file
@@ -33,6 +34,7 @@ interface ReaderStore {
setBookKeys: (keys: string[]) => void;
setHoveredBookKey: (key: string | null) => void;
setBookmarkRibbonVisibility: (key: string, visible: boolean) => void;
setTTSEnabled: (key: string, enabled: boolean) => void;
setProgress: (
key: string,
location: string,
@@ -103,6 +105,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
viewSettings: null,
},
},
@@ -119,7 +122,9 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
const content = (await appService.loadBookContent(book, settings)) as BookContent;
const { file, config } = content;
console.log('Loading book', key);
const { book: loadedBookDoc } = await new DocumentLoader(file).open();
const { book: loadedBookDoc } = await new DocumentLoader(file).open({
allowScript: config.viewSettings?.allowScript,
});
const bookDoc = loadedBookDoc as BookDoc;
if (bookDoc.toc?.length && bookDoc.sections?.length) {
updateToc(bookDoc, bookDoc.toc, bookDoc.sections);
@@ -149,6 +154,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
error: null,
progress: null,
ribbonVisible: false,
ttsEnabled: false,
viewSettings: JSON.parse(JSON.stringify(configViewSettings)) as ViewSettings,
},
},
@@ -167,6 +173,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
error: 'Failed to load book.',
progress: null,
ribbonVisible: false,
ttsEnabled: false,
viewSettings: null,
},
},
@@ -282,4 +289,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
},
},
})),
setTTSEnabled: (key: string, enabled: boolean) =>
set((state) => ({
viewStates: {
...state.viewStates,
[key]: {
...state.viewStates[key]!,
ttsEnabled: enabled,
},
},
})),
}));
+2
View File
@@ -88,6 +88,7 @@ export interface BookLayout {
showFooter: boolean;
showBarsOnScroll: boolean;
scrollingOverlap: number;
allowScript: boolean;
}
export interface BookStyle {
@@ -125,6 +126,7 @@ export interface ViewConfig {
export interface TTSConfig {
ttsRate: number;
ttsVoice: string;
ttsLocation: string;
}
export interface TranslatorConfig {
+5 -3
View File
@@ -1,10 +1,12 @@
export type UserStorageQuota = {
export interface UserQuota {
free: number;
plus: number;
pro: number;
};
}
export type UserPlan = keyof UserStorageQuota;
export type UserPlan = keyof UserQuota;
export type UserStorageQuota = UserQuota;
export type UserDailyTranslationQuota = UserQuota;
export type QuotaType = {
name: string;
+1
View File
@@ -15,6 +15,7 @@ export interface FoliateView extends HTMLElement {
goLeft: () => void;
goRight: () => void;
getCFI: (index: number, range: Range) => string;
resolveCFI: (cfi: string) => { index: number; anchor: (doc: Document) => Range };
addAnnotation: (note: BookNote, remove?: boolean) => { index: number; label: string };
search: (config: BookSearchConfig) => AsyncGenerator<BookSearchResult | string, void, void>;
clearSearch: () => void;
+14 -1
View File
@@ -1,6 +1,6 @@
import { jwtDecode } from 'jwt-decode';
import { UserPlan } from '@/types/user';
import { DEFAULT_STORAGE_QUOTA } from '@/services/constants';
import { DEFAULT_DAILY_TRANSLATION_QUOTA, DEFAULT_STORAGE_QUOTA } from '@/services/constants';
import { isWebAppPlatform } from '@/services/environment';
import { supabase } from '@/utils/supabase';
@@ -29,6 +29,19 @@ export const getStoragePlanData = (token: string) => {
};
};
export const getDailyTranslationPlanData = (token: string) => {
const data = jwtDecode<Token>(token) || {};
const plan = data['plan'] || 'free';
const fixedQuota = parseInt(process.env['NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA'] || '0');
const quota =
fixedQuota || DEFAULT_DAILY_TRANSLATION_QUOTA[plan] || DEFAULT_DAILY_TRANSLATION_QUOTA['free'];
return {
plan,
quota,
};
};
export const getAccessToken = async (): Promise<string | null> => {
// In browser context there might be two instances of supabase one in the app route
// and the other in the pages route, and they might have different sessions
+1 -6
View File
@@ -1,9 +1,4 @@
type TTSMark = {
offset: number;
name: string;
text: string;
language?: string;
};
import { TTSMark } from '@/services/tts/types';
const cleanTextContent = (text: string) =>
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
+6 -3
View File
@@ -239,7 +239,7 @@ const getLayoutStyles = (
word-spacing: ${wordSpacing}px ${overrideLayout ? '!important' : ''};
letter-spacing: ${letterSpacing}px ${overrideLayout ? '!important' : ''};
text-indent: ${vertical ? textIndent * 1.2 : textIndent}em ${overrideLayout ? '!important' : ''};
text-align: ${justify ? 'justify' : 'inherit'} ${overrideLayout ? '!important' : ''};
text-align: ${justify ? 'justify' : ''} ${overrideLayout ? '!important' : ''};
-webkit-hyphens: ${hyphenate ? 'auto' : 'manual'};
hyphens: ${hyphenate ? 'auto' : 'manual'};
-webkit-hyphenate-limit-before: 3;
@@ -248,8 +248,7 @@ const getLayoutStyles = (
hanging-punctuation: allow-end last;
widows: 2;
}
p:has(> img:only-child), p:has(> span:only-child > img:only-child),
div[style*="text-align"] {
p:has(> img:only-child), p:has(> span:only-child > img:only-child) {
text-indent: unset !important;
}
p, div {
@@ -318,6 +317,10 @@ const getLayoutStyles = (
div.center *, p.center * { text-align: center; }
div.justify *, p.justify * { text-align: justify; }
.nonindent, .noindent {
text-indent: unset !important;
}
/* for the Gutenberg eBooks */
#pg-header * {
color: inherit !important;
+12 -5
View File
@@ -59,31 +59,38 @@ export const updateToc = (bookDoc: BookDoc, items: TOCItem[], sections: SectionI
map[section.id] = section;
return map;
}, {});
updateTocData(bookDoc, items, sectionsMap);
updateTocData(bookDoc, items, sections, sectionsMap);
items.sort((a, b) => {
if (a.location && b.location) {
return a.location.current - b.location.current;
}
return 0;
});
};
const updateTocData = (
bookDoc: BookDoc,
items: TOCItem[],
sections: { [id: string]: SectionItem },
sections: SectionItem[],
sectionsMap: { [id: string]: SectionItem },
index = 0,
): number => {
items.forEach((item) => {
item.id ??= index++;
if (item.href) {
const id = bookDoc.splitTOCHref(item.href)[0]!;
const section = sections[id];
const section = sectionsMap[id];
if (section) {
item.cfi = section.cfi;
// Add location only when toc item is at the same level as the section
// otherwise the location will not be accurate
if (id === item.href) {
if (id === item.href || items.length <= sections.length) {
item.location = section.location;
}
}
}
if (item.subitems) {
index = updateTocData(bookDoc, item.subitems, sections, index);
index = updateTocData(bookDoc, item.subitems, sections, sectionsMap, index);
}
});
return index;