Compare commits

...

47 Commits

Author SHA1 Message Date
Huang Xin 05e9549410 release: version 0.9.88 (#2258) 2025-10-17 18:06:00 +02:00
Huang Xin 35fbce3506 feat(settings): added an option to disable animation in E-Ink mode, closes #2253 (#2257) 2025-10-17 18:00:58 +02:00
Huang Xin c1d6825793 fix: more sensitive snap to paginate, closes #2252 (#2256) 2025-10-17 16:05:39 +02:00
Huang Xin ae6c6970b4 feat: localize number in progress info in vertical layout (#2255) 2025-10-17 12:17:36 +02:00
Huang Xin f8fef57cf3 fix(layout): overriding book layout no longer overrides explicit text alignment, closes #2249 (#2254) 2025-10-17 11:44:32 +02:00
Huang Xin 4dc191735d fix(layout): prevent TTS indicator from disappearing too quickly to open configuration panel, closes #2247 (#2248) 2025-10-16 16:49:05 +02:00
Huang Xin 4f11f437e1 release: version 0.9.87 (#2245) 2025-10-16 10:27:34 +02:00
Huang Xin 5773e31990 compat(tts): fix crash with TTS on some versions of Android systems (#2244) 2025-10-16 10:10:04 +02:00
Huang Xin 9dda5d4c88 fix(tts): scroll more accurately to the highlighted text in TTS when header/footer bars are shown (#2242) 2025-10-15 17:50:56 +02:00
Huang Xin 7f5b6959e1 font: update font LXGW WenKai to the latest version (#2240) 2025-10-15 15:17:53 +02:00
Huang Xin 126981d085 fix(theme): fixed auto theme mode for new reader window (#2238) 2025-10-15 14:36:02 +02:00
Huang Xin 854a578929 compat: support parsing more footnotes, closes #2234 (#2237) 2025-10-15 11:59:27 +02:00
Huang Xin 6dd0daa597 txt: add chapter title pattern, closes #2232 (#2233) 2025-10-14 18:01:23 +02:00
Huang Xin fd24a5e9ac release: version 0.9.86 (#2230) 2025-10-14 14:08:45 +02:00
Huang Xin 948b35f244 perf: support concurrent book uploading, closes #2208 (#2229) 2025-10-14 14:00:31 +02:00
Huang Xin 96cc182a8c feat: support overriding html language code with language code in metadata, closes #2222 (#2227) 2025-10-14 13:09:23 +02:00
Huang Xin e2291ed5b1 feat(koplugin): register sync actions in gesture manager closes #2224 (#2226) 2025-10-14 09:54:08 +02:00
Huang Xin 60ddb17547 feat: add support for per-book background image settings in addition to global settings, also closes #2223 (#2225) 2025-10-14 08:54:26 +02:00
Huang Xin 9a991a31ba fix: handle empty custom textures (#2221) 2025-10-13 20:08:22 +02:00
Huang Xin 6de10c6c3b release: version 0.9.85 (#2220) 2025-10-13 19:15:55 +02:00
Huang Xin c7d49c1504 fix: load custom texture before applying it (#2219) 2025-10-13 19:04:45 +02:00
Huang Xin c721bd4d97 chore: enable turbopack by default in development env (#2218) 2025-10-13 16:39:17 +02:00
Huang Xin 5b0a1968d8 chore: add readest.koplugin in the release workflow (#2217) 2025-10-13 16:26:11 +02:00
Huang Xin ad9064fa46 fix(toc): fixed issues of scroll to active items in unpinned sidebar, closes #2213 (#2216) 2025-10-13 15:29:51 +02:00
Huang Xin f1c92e5702 feat: support customizing background images, closes #2018 (#2214) 2025-10-13 14:04:13 +02:00
Huang Xin 92c1441922 translator: add translator target language of Persian, closes #2206 (#2212) 2025-10-12 13:53:23 +02:00
Huang Xin 3820950c7c fix: show only the filtered books in groups (#2209) 2025-10-12 11:38:47 +02:00
Huang Xin 08e558bd41 fix: actually allow script in the transform target, closes #2200 (#2203) 2025-10-11 12:59:55 +02:00
Huang Xin 00af4a3d60 layout: make TTS icon less distracting during read-aloud mode (#2198) 2025-10-10 19:14:57 +02:00
Huang Xin ada427b134 feat(settings): add settings to adjust screen brightness when reading, closes #1532 (#2197) 2025-10-10 19:02:35 +02:00
Huang Xin f696b9a573 fix: handle scrolling/panning in zoomed in PDFs, closes #2184 (#2194) 2025-10-10 07:56:41 +02:00
Huang Xin 195beeacac chore: don't init posthog if users opt-out the usage data telemetry, closes #2187 (#2192) 2025-10-10 07:03:49 +02:00
Huang Xin 5b23ed35b6 txt: properly detect chapters in TXT files with dot-number format, closes #2188 (#2191) 2025-10-10 06:45:50 +02:00
Huang Xin 42c8702704 fix(cbz): fix issue where CBZ files fail to reopen after being closed, closes #2183 (#2190) 2025-10-10 05:50:40 +02:00
Huang Xin ad81376da8 fix(css): auto height of inline images with object-fit style (#2189) 2025-10-10 04:10:05 +02:00
Huang Xin 562fea3a36 fix(layout): fix inconsistent background color for unpinned drag bars (#2186) 2025-10-09 18:35:00 +02:00
Huang Xin b69d9ed69f tts: improve media session control compatibility across more Android systems (#2185) 2025-10-09 17:53:07 +02:00
Huang Xin ad6a21a68f refactor: split FooterBar into modular components for desktop and mobile (#2181) 2025-10-08 07:47:39 +02:00
Huang Xin 173aa0fcd1 layout: fix dragbar flickering when opening a book (#2177) 2025-10-06 05:52:36 +02:00
Huang Xin 788e08a75a shortcut: use shift+space to go backward in the reader page (#2176) 2025-10-06 05:31:16 +02:00
Huang Xin ec397a2daf fix: draw annotations of the current section on load, closes #2163 (#2174) 2025-10-05 17:47:34 +02:00
Huang Xin 2a5758fbc4 compat: disable customize root dir for macOS App Store builds (#2171)
CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions.
See: https://github.com/tauri-apps/tauri/issues/3716
2025-10-05 08:28:45 +02:00
Huang Xin 562ccd4b9e feat: add keyboard shortcuts for toggling bookmarks/sidebar, importing books, closing window (#2170)
And make the shortcuts compatible with GNOME Human Interface Guidelines.
This closes #2079 and closes #2168.
2025-10-04 16:12:31 +02:00
make_aguess 4d577774f3 Adds adaptive icon for Android launcher (#2162)
Introduces an adaptive icon for the Android launcher, defining
separate layers for background, foreground, and monochrome
elements. Enhances visual consistency and supports adaptive
icon features on modern Android devices.
2025-10-04 09:00:33 +02:00
Huang Xin 4c10c16491 i18n: add translations for Swedish(sv) (#2159) 2025-10-02 14:25:01 +02:00
Huang Xin 7695f31a5a translator: translate to and from Norwegian Bokmål(nb) (#2158) 2025-10-02 14:09:01 +02:00
Huang Xin 06b27f8d39 chore: fix portable exe build for Windows (#2156) 2025-10-02 00:11:21 +08:00
132 changed files with 3957 additions and 964 deletions
+24
View File
@@ -81,6 +81,28 @@ jobs:
prerelease: false
})
build-koreader-plugin:
needs: get-release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: create KOReader plugin zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=${{ needs.get-release.outputs.release_version }}
plugin_zip="Readest-${version}-1.koplugin.zip"
cd apps/readest.koplugin
zip -r ../../${plugin_zip} .
cd ../..
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
build-tauri:
needs: get-release
permissions:
@@ -292,6 +314,8 @@ jobs:
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
echo "Building Portable Binaries"
@@ -18,6 +18,7 @@ module.exports = {
'ko',
'uk',
'nl',
'sv',
'pl',
'pt',
'ru',
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@readest/readest-app",
"version": "0.9.82",
"version": "0.9.88",
"private": true,
"scripts": {
"dev": "dotenv -e .env.tauri -- next dev",
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
"build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev",
"dev-web": "dotenv -e .env.web -- next dev --turbopack",
"build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start",
"i18n:extract": "i18next-scanner",
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

@@ -615,5 +615,15 @@
"File count: {{size}}": "عدد الملفات: {{size}}",
"Background Read Aloud": "قراءة بصوت عالٍ في الخلفية",
"Ready to read aloud": "جاهز للقراءة بصوت عالٍ",
"Read Aloud": "القراءة بصوت عالٍ"
"Read Aloud": "القراءة بصوت عالٍ",
"Screen Brightness": "سطوع الشاشة",
"Background Image": "صورة الخلفية",
"Import Image": "استيراد صورة",
"Opacity": "شفافية",
"Size": "حجم",
"Cover": "غطاء",
"Contain": "احتواء",
"{{number}} pages left in chapter": "تبقّت {{number}} صفحات في هذا الفصل",
"Device": "الجهاز",
"E-Ink Mode": "وضع الحبر الإلكتروني"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "ফাইলের সংখ্যা: {{size}}",
"Background Read Aloud": "পটভূমিতে উচ্চস্বরে পড়ুন",
"Ready to read aloud": "উচ্চস্বরে পড়ার জন্য প্রস্তুত",
"Read Aloud": "উচ্চস্বরে পড়ুন"
"Read Aloud": "উচ্চস্বরে পড়ুন",
"Screen Brightness": "স্ক্রিন উজ্জ্বলতা",
"Background Image": "ব্যাকগ্রাউন্ড ছবি",
"Import Image": "ছবি আমদানি করুন",
"Opacity": "স্বচ্ছতা",
"Size": "আকার",
"Cover": "ঢাকা",
"Contain": "ধারণ",
"{{number}} pages left in chapter": "অধ্যায়ে {{number}} পৃষ্ঠা বাকি",
"Device": "যন্ত্র",
"E-Ink Mode": "ই-ইঙ্ক মোড"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}",
"Background Read Aloud": "གནས་སྟངས་ཀྱིས་ཀློག་བྱེད།",
"Ready to read aloud": "དེབ་ཀྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Read Aloud": "ཀློག་བྱེད།"
"Read Aloud": "ཀློག་བྱེད།",
"Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར།",
"Background Image": "གནས་སྟངས་ཀྱི་རི་མོ།",
"Import Image": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
"Opacity": "སྤོ་སྐོར་གྱི་འོད་ཟེར།",
"Size": "ཨང་",
"Cover": "དེབ་གྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Contain": "འབྱོར་བ།",
"{{number}} pages left in chapter": "དོན་ཚན་ནང་ཤོག་ཨང་ {{number}} ལོག་གི་འདུག།",
"Device": "རྐྱབ་སྐོར",
"E-Ink Mode": "ཡིག་སྒྱུར་སྤོ་བ"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "Dateianzahl: {{size}}",
"Background Read Aloud": "Hintergrund-Vorlesen",
"Ready to read aloud": "Bereit zum Vorlesen",
"Read Aloud": "Vorlesen"
"Read Aloud": "Vorlesen",
"Screen Brightness": "Bildschirmhelligkeit",
"Background Image": "Hintergrundbild",
"Import Image": "Bild importieren",
"Opacity": "Opazität",
"Size": "Größe",
"Cover": "Cover",
"Contain": "Inhalt",
"{{number}} pages left in chapter": "{{number}} Seiten verbleibend im Kapitel",
"Device": "Gerät",
"E-Ink Mode": "E-Ink-Modus"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "Αριθμός αρχείων: {{size}}",
"Background Read Aloud": "Ανάγνωση στο παρασκήνιο",
"Ready to read aloud": "Έτοιμο για ανάγνωση",
"Read Aloud": "Ανάγνωση"
"Read Aloud": "Ανάγνωση",
"Screen Brightness": "Φωτεινότητα οθόνης",
"Background Image": "Εικόνα φόντου",
"Import Image": "Εισαγωγή εικόνας",
"Opacity": "Αδιαφάνεια",
"Size": "Μέγεθος",
"Cover": "Εξώφυλλο",
"Contain": "Περιέχει",
"{{number}} pages left in chapter": "Μένουν {{number}} σελίδες στο κεφάλαιο",
"Device": "Συσκευή",
"E-Ink Mode": "Λειτουργία E-Ink"
}
@@ -603,5 +603,15 @@
"File count: {{size}}": "Número de archivos: {{size}}",
"Background Read Aloud": "Lectura en segundo plano",
"Ready to read aloud": "Listo para leer en voz alta",
"Read Aloud": "Leer en voz alta"
"Read Aloud": "Leer en voz alta",
"Screen Brightness": "Brillo de pantalla",
"Background Image": "Imagen de fondo",
"Import Image": "Importar imagen",
"Opacity": "Opacidad",
"Size": "Tamaño",
"Cover": "Cubierta",
"Contain": "Contener",
"{{number}} pages left in chapter": "{{number}} páginas restantes en el capítulo",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink"
}
@@ -603,5 +603,15 @@
"File count: {{size}}": "Nombre de fichiers : {{size}}",
"Background Read Aloud": "Lecture Vocale en Arrière-Plan",
"Ready to read aloud": "Prêt à lire à haute voix",
"Read Aloud": "Lire à haute voix"
"Read Aloud": "Lire à haute voix",
"Screen Brightness": "Luminosité de l'Écran",
"Background Image": "Image de Fond",
"Import Image": "Importer une Image",
"Opacity": "Opacité",
"Size": "Taille",
"Cover": "Couverture",
"Contain": "Contenir",
"{{number}} pages left in chapter": "{{number}} pages restantes dans le chapitre",
"Device": "Appareil",
"E-Ink Mode": "Mode E-Ink"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "फ़ाइलों की संख्या: {{size}}",
"Background Read Aloud": "पृष्ठभूमि में वाचन",
"Ready to read aloud": "उच्च स्वर में पढ़ने के लिए तैयार",
"Read Aloud": "उच्च स्वर में पढ़ें"
"Read Aloud": "उच्च स्वर में पढ़ें",
"Screen Brightness": "स्क्रीन ब्राइटनेस",
"Background Image": "पृष्ठभूमि छवि",
"Import Image": "छवि आयात करें",
"Opacity": "अस्पष्टता",
"Size": "आकार",
"Cover": "कवर",
"Contain": "समाहित करें",
"{{number}} pages left in chapter": "इस अध्याय में {{number}} पृष्ठ शेष हैं",
"Device": "डिवाइस",
"E-Ink Mode": "ई-इंक मोड"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "Jumlah file: {{size}}",
"Background Read Aloud": "Bacakan Latar Belakang",
"Ready to read aloud": "Siap untuk dibacakan",
"Read Aloud": "Bacakan"
"Read Aloud": "Bacakan",
"Screen Brightness": "Kecerahan Layar",
"Background Image": "Gambar Latar Belakang",
"Import Image": "Impor Gambar",
"Opacity": "Keburaman",
"Size": "Ukuran",
"Cover": "Sampul",
"Contain": "Mengandung",
"{{number}} pages left in chapter": "{{number}} halaman tersisa di bab",
"Device": "Perangkat",
"E-Ink Mode": "Mode E-Ink"
}
@@ -603,5 +603,15 @@
"File count: {{size}}": "Conteggio file: {{size}}",
"Background Read Aloud": "Riproduzione in background",
"Ready to read aloud": "Pronto per la lettura ad alta voce",
"Read Aloud": "Leggi ad alta voce"
"Read Aloud": "Leggi ad alta voce",
"Screen Brightness": "Luminosità dello schermo",
"Background Image": "Immagine di sfondo",
"Import Image": "Importa immagine",
"Opacity": "Opacità",
"Size": "Dimensione",
"Cover": "Copertura",
"Contain": "Contenere",
"{{number}} pages left in chapter": "{{number}} pagine rimaste nel capitolo",
"Device": "Dispositivo",
"E-Ink Mode": "Modalità E-Ink"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "ファイル数: {{size}}",
"Background Read Aloud": "バックグラウンド読み上げ",
"Ready to read aloud": "読み上げの準備ができました",
"Read Aloud": "読み上げ"
"Read Aloud": "読み上げ",
"Screen Brightness": "画面の明るさ",
"Background Image": "背景画像",
"Import Image": "画像をインポート",
"Opacity": "不透明度",
"Size": "サイズ",
"Cover": "カバー",
"Contain": "含む",
"{{number}} pages left in chapter": "章に{{number}}ページ残り",
"Device": "デバイス",
"E-Ink Mode": "E-Inkモード"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "파일 수: {{size}}",
"Background Read Aloud": "백그라운드 음성 읽기",
"Ready to read aloud": "읽기 준비 완료",
"Read Aloud": "음성 읽기"
"Read Aloud": "음성 읽기",
"Screen Brightness": "화면 밝기",
"Background Image": "배경 이미지",
"Import Image": "이미지 가져오기",
"Opacity": "불투명도",
"Size": "크기",
"Cover": "표지",
"Contain": "포함",
"{{number}} pages left in chapter": "남은 페이지 {{number}}장",
"Device": "장치",
"E-Ink Mode": "E-Ink 모드"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "Bestandsaantal: {{size}}",
"Background Read Aloud": "Achtergrond Voorlezen",
"Ready to read aloud": "Klaar om voor te lezen",
"Read Aloud": "Voorlezen"
"Read Aloud": "Voorlezen",
"Screen Brightness": "Schermhelderheid",
"Background Image": "Achtergrondafbeelding",
"Import Image": "Afbeelding importeren",
"Opacity": "Doorzichtigheid",
"Size": "Grootte",
"Cover": "Omslag",
"Contain": "Bevatten",
"{{number}} pages left in chapter": "Nog {{number}} paginas in hoofdstuk",
"Device": "Apparaat",
"E-Ink Mode": "E-Ink Modus"
}
@@ -607,5 +607,15 @@
"File count: {{size}}": "Liczba plików: {{size}}",
"Background Read Aloud": "Czytanie na głos w tle",
"Ready to read aloud": "Gotowy do przeczytania na głos",
"Read Aloud": "Czytaj na głos"
"Read Aloud": "Czytaj na głos",
"Screen Brightness": "Jasność ekranu",
"Background Image": "Obraz tła",
"Import Image": "Importuj obraz",
"Opacity": "Przezroczystość",
"Size": "Rozmiar",
"Cover": "Okładka",
"Contain": "Zawierać",
"{{number}} pages left in chapter": "Pozostało {{number}} stron w rozdziale",
"Device": "Urządzenie",
"E-Ink Mode": "Tryb E-Ink"
}
@@ -603,5 +603,15 @@
"File count: {{size}}": "Contagem de arquivos: {{size}}",
"Background Read Aloud": "Leitura em Segundo Plano",
"Ready to read aloud": "Pronto para leitura em voz alta",
"Read Aloud": "Ler em Voz Alta"
"Read Aloud": "Ler em Voz Alta",
"Screen Brightness": "Brilho da Tela",
"Background Image": "Imagem de Fundo",
"Import Image": "Importar Imagem",
"Opacity": "Opacidade",
"Size": "Tamanho",
"Cover": "Capa",
"Contain": "Contém",
"{{number}} pages left in chapter": "Faltam {{number}} páginas neste capítulo",
"Device": "Dispositivo",
"E-Ink Mode": "Modo E-Ink"
}
@@ -607,5 +607,15 @@
"File count: {{size}}": "Количество файлов: {{size}}",
"Background Read Aloud": "Озвучивание в фоне",
"Ready to read aloud": "Готов к чтению вслух",
"Read Aloud": "Читать вслух"
"Read Aloud": "Читать вслух",
"Screen Brightness": "Яркость экрана",
"Background Image": "Фоновое изображение",
"Import Image": "Импортировать изображение",
"Opacity": "Непрозрачность",
"Size": "Размер",
"Cover": "Обложка",
"Contain": "Содержать",
"{{number}} pages left in chapter": "Осталось {{number}} страниц в главе",
"Device": "Устройство",
"E-Ink Mode": "E-Ink режим"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "ගොනු ගණන: {{size}}",
"Background Read Aloud": "පසුබිම් පවසා කියවීම",
"Ready to read aloud": "වාදනය කිරීමට සූදානම්",
"Read Aloud": "පවසා කියවීම"
"Read Aloud": "පවසා කියවීම",
"Screen Brightness": "තිරයේ දිදුලනුම",
"Background Image": "පසුබිම් රූපය",
"Import Image": "රූපය ආයාත කරන්න",
"Opacity": "පැහැදිලිත්වය",
"Size": "ප්‍රමාණය",
"Cover": "ආවරණය",
"Contain": "අඩංගු වන්න",
"{{number}} pages left in chapter": "පරිච්ඡේදයේ පිටු {{number}} ඉතිරිව ඇත",
"Device": "උපකරණය",
"E-Ink Mode": "E-Ink ආකාරය"
}
@@ -0,0 +1,613 @@
{
"Email address": "E-postadress",
"Your Password": "Ditt lösenord",
"Your email address": "Din e-postadress",
"Your password": "Ditt lösenord",
"Sign in": "Logga in",
"Signing in...": "Loggar in...",
"Sign in with {{provider}}": "Logga in med {{provider}}",
"Already have an account? Sign in": "Har du konto? Logga in",
"Create a Password": "Skapa lösenord",
"Sign up": "Registrera",
"Signing up...": "Registrerar...",
"Don't have an account? Sign up": "Inget konto? Registrera",
"Check your email for the confirmation link": "Kolla din e-post för bekräftelselänk",
"Signing in ...": "Loggar in...",
"Send a magic link email": "Skicka magisk länk",
"Check your email for the magic link": "Kolla din e-post för magisk länk",
"Send reset password instructions": "Skicka återställningsinstruktioner",
"Sending reset instructions ...": "Skickar instruktioner...",
"Forgot your password?": "Glömt lösenord?",
"Check your email for the password reset link": "Kolla din e-post för återställningslänk",
"Phone number": "Telefonnummer",
"Your phone number": "Ditt telefonnummer",
"Token": "Token",
"Your OTP token": "Din OTP-token",
"Verify token": "Verifiera token",
"Go Back": "Tillbaka",
"Sign in with Google": "Logga in med Google",
"Sign in with Apple": "Logga in med Apple",
"Sign in with GitHub": "Logga in med GitHub",
"New Password": "Nytt lösenord",
"Your new password": "Ditt nya lösenord",
"Update password": "Uppdatera lösenord",
"Updating password ...": "Uppdaterar lösenord...",
"Your password has been updated": "Ditt lösenord har uppdaterats",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "Något gick fel. Oroa dig inte, vårt team har meddelats och vi arbetar på en lösning.",
"Error Details:": "Feldetaljer:",
"Try Again": "Försök igen",
"Your Library": "Ditt bibliotek",
"Need help?": "Behöver du hjälp?",
"Contact Support": "Kontakta support",
"Show Book Details": "Visa bokdetaljer",
"Bookshelf": "Bokhylla",
"Import Books": "Importera böcker",
"Open": "Öppna",
"Group": "Grupp",
"Details": "Detaljer",
"Delete": "Ta bort",
"Cancel": "Avbryt",
"Confirm Deletion": "Bekräfta borttagning",
"Are you sure to delete {{count}} selected book(s)?_one": "Vill du ta bort {{count}} vald bok?",
"Are you sure to delete {{count}} selected book(s)?_other": "Vill du ta bort {{count}} valda böcker?",
"Deselect Book": "Avmarkera bok",
"Select Book": "Välj bok",
"Download Book": "Ladda ner bok",
"Upload Book": "Ladda upp bok",
"Deselect Group": "Avmarkera grupp",
"Select Group": "Välj grupp",
"Untitled Group": "Namnlös grupp",
"Group Books": "Gruppera böcker",
"Remove From Group": "Ta bort från grupp",
"Create New Group": "Skapa ny grupp",
"Save": "Spara",
"Confirm": "Bekräfta",
"From Local File": "Från lokal fil",
"Search in {{count}} Book(s)..._one": "Sök i {{count}} bok...",
"Search in {{count}} Book(s)..._other": "Sök i {{count}} böcker...",
"Search Books...": "Sök böcker...",
"Clear Search": "Rensa sökning",
"Select Books": "Välj böcker",
"Deselect": "Avmarkera",
"Select All": "Välj alla",
"View Menu": "Visa meny",
"Settings Menu": "Inställningsmeny",
"Failed to select directory": "Kunde inte välja katalog",
"The new data directory must be different from the current one.": "Den nya datakatalogen måste skilja sig från den nuvarande.",
"Migration failed: {{error}}": "Migrering misslyckades: {{error}}",
"Change Data Location": "Ändra dataplats",
"Current Data Location": "Nuvarande dataplats",
"Loading...": "Laddar...",
"File count: {{size}}": "Antal filer: {{size}}",
"Total size: {{size}}": "Total storlek: {{size}}",
"Calculating file info...": "Beräknar filinfo...",
"New Data Location": "Ny dataplats",
"Choose New Folder": "Välj ny mapp",
"Choose Different Folder": "Välj annan mapp",
"Migrating data...": "Migrerar data...",
"Copying: {{file}}": "Kopierar: {{file}}",
"{{current}} of {{total}} files": "{{current}} av {{total}} filer",
"Migration completed successfully!": "Migrering slutförd!",
"Your data has been moved to the new location. Please restart the application to complete the process.": "Din data har flyttats till den nya platsen. Starta om appen för att slutföra processen.",
"Migration failed": "Migrering misslyckades",
"Important Notice": "Viktigt meddelande",
"This will move all your app data to the new location. Make sure the destination has enough free space.": "Detta flyttar all din appdata till den nya platsen. Se till att målet har tillräckligt med utrymme.",
"Close": "Stäng",
"Restart App": "Starta om app",
"Start Migration": "Starta migrering",
"Dark Mode": "Mörkt läge",
"Light Mode": "Ljust läge",
"Auto Mode": "Auto-läge",
"Logged in as {{userDisplayName}}": "Inloggad som {{userDisplayName}}",
"Logged in": "Inloggad",
"View account details and quota": "Visa kontodetaljer och kvot",
"Account": "Konto",
"Sign In": "Logga in",
"Auto Upload Books to Cloud": "Auto-ladda upp böcker till moln",
"Auto Import on File Open": "Auto-importera vid filöppning",
"Open Last Book on Start": "Öppna senaste bok vid start",
"Check Updates on Start": "Sök uppdateringar vid start",
"Open Book in New Window": "Öppna bok i nytt fönster",
"Fullscreen": "Helskärm",
"Always on Top": "Alltid överst",
"Always Show Status Bar": "Visa alltid statusfält",
"Keep Screen Awake": "Håll skärm vaken",
"Background Read Aloud": "Läs upp i bakgrunden",
"Reload Page": "Ladda om sida",
"Settings": "Inställningar",
"Advanced Settings": "Avancerade inställningar",
"Upgrade to Readest Premium": "Uppgradera till Readest Premium",
"Download Readest": "Ladda ner Readest",
"About Readest": "Om Readest",
"Help improve Readest": "Hjälp förbättra Readest",
"Sharing anonymized statistics": "Dela anonymiserad statistik",
"List": "Lista",
"Grid": "Rutnät",
"Crop": "Beskär",
"Fit": "Anpassa",
"Title": "Titel",
"Author": "Författare",
"Format": "Format",
"Date Read": "Läsdatum",
"Date Added": "Datum tillagd",
"Ascending": "Stigande",
"Descending": "Fallande",
"Book Covers": "Bokomslag",
"Sort by...": "Sortera efter...",
"No supported files found. Supported formats: {{formats}}": "Inga filer hittades. Format som stöds: {{formats}}",
"No chapters detected.": "Inga kapitel hittades.",
"Failed to parse the EPUB file.": "Kunde inte läsa EPUB-filen.",
"This book format is not supported.": "Detta bokformat stöds inte.",
"Failed to import book(s): {{filenames}}": "Kunde inte importera bok(er): {{filenames}}",
"Book uploaded: {{title}}": "Bok uppladdad: {{title}}",
"Insufficient storage quota": "Otillräcklig lagringskvot",
"Failed to upload book: {{title}}": "Kunde inte ladda upp bok: {{title}}",
"Book downloaded: {{title}}": "Bok nedladdad: {{title}}",
"Failed to download book: {{title}}": "Kunde inte ladda ner bok: {{title}}",
"Book deleted: {{title}}": "Bok borttagen: {{title}}",
"Deleted cloud backup of the book: {{title}}": "Raderade molnkopia av bok: {{title}}",
"Deleted local copy of the book: {{title}}": "Raderade lokal kopia av bok: {{title}}",
"Failed to delete book: {{title}}": "Kunde inte ta bort bok: {{title}}",
"Failed to delete cloud backup of the book: {{title}}": "Kunde inte radera molnkopia av bok: {{title}}",
"Failed to delete local copy of the book: {{title}}": "Kunde inte radera lokal kopia av bok: {{title}}",
"Library Header": "Bibliotekshuvud",
"Welcome to your library. You can import your books here and read them anytime.": "Välkommen till ditt bibliotek. Du kan importera dina böcker här och läsa dem när som helst.",
"Copied to notebook": "Kopierat till anteckningsbok",
"No annotations to export": "Inga anteckningar att exportera",
"Exported from Readest": "Exporterad från Readest",
"Highlights & Annotations": "Markeringar & anteckningar",
"Untitled": "Namnlös",
"Note": "Anteckning",
"Copied to clipboard": "Kopierat",
"Copy": "Kopiera",
"Delete Highlight": "Ta bort markering",
"Highlight": "Markera",
"Annotate": "Anteckna",
"Search": "Sök",
"Dictionary": "Ordbok",
"Wikipedia": "Wikipedia",
"Translate": "Översätt",
"Speak": "Läs upp",
"Login Required": "Inloggning krävs",
"Quota Exceeded": "Kvot överskriden",
"Unable to fetch the translation. Please log in first and try again.": "Kunde inte hämta översättning. Logga in och försök igen.",
"Unable to fetch the translation. Try again later.": "Kunde inte hämta översättning. Försök senare.",
"Original Text": "Originaltext",
"Auto Detect": "Auto-identifiera",
"(detected)": "(identifierad)",
"Translated Text": "Översatt text",
"System Language": "Systemspråk",
"No translation available.": "Ingen översättning tillgänglig.",
"Translated by {{provider}}.": "Översatt av {{provider}}.",
"Remove Bookmark": "Ta bort bokmärke",
"Add Bookmark": "Lägg till bokmärke",
"Books Content": "Bokinnehåll",
"Book Content": "Bokinnehåll",
"Footer Bar": "Sidfot",
"Reading Progress": "Läsframsteg",
"Next Section": "Nästa sektion",
"Previous Section": "Föregående sektion",
"Next Page": "Nästa sida",
"Previous Page": "Föregående sida",
"Go Forward": "Framåt",
"Font Size": "Teckenstorlek",
"Page Margin": "Sidmarginal",
"Small": "Liten",
"Large": "Stor",
"Line Spacing": "Radavstånd",
"Table of Contents": "Innehåll",
"Notes": "Anteckningar",
"Font & Layout": "Typsnitt & layout",
"Jump to Location": "Hoppa till plats",
"Header Bar": "Sidhuvud",
"View Options": "Visningsalternativ",
"Sync Conflict": "Synkkonflikt",
"Sync reading progress from \"{{deviceName}}\"?": "Synka läsframsteg från \"{{deviceName}}\"?",
"another device": "annan enhet",
"Local Progress": "Lokalt framsteg",
"Remote Progress": "Fjärrframsteg",
"Failed to connect": "Kunde inte ansluta",
"Disconnected": "Frånkopplad",
"KOReader Sync Settings": "KOReader-synkinställningar",
"Sync as {{userDisplayName}}": "Synka som {{userDisplayName}}",
"Sync Server Connected": "Synkserver ansluten",
"Sync Strategy": "Synkstrategi",
"Ask on conflict": "Fråga vid konflikt",
"Always use latest": "Använd alltid senaste",
"Send changes only": "Skicka endast ändringar",
"Receive changes only": "Ta endast emot ändringar",
"Checksum Method": "Kontrollsummemetod",
"File Content (recommended)": "Filinnehåll (rekommenderas)",
"File Name": "Filnamn",
"Device Name": "Enhetsnamn",
"Connect to your KOReader Sync server.": "Anslut till din KOReader-synkserver.",
"Server URL": "Server-URL",
"Username": "Användarnamn",
"Your Username": "Ditt användarnamn",
"Password": "Lösenord",
"Connect": "Anslut",
"Notebook": "Anteckningsbok",
"Unpin Notebook": "Lossa anteckningsbok",
"Pin Notebook": "Fäst anteckningsbok",
"Hide Search Bar": "Dölj sökfält",
"Show Search Bar": "Visa sökfält",
"Resize Notebook": "Ändra storlek på anteckningsbok",
"No notes match your search": "Inga anteckningar matchar din sökning",
"Excerpts": "Utdrag",
"Add your notes here...": "Lägg till dina anteckningar här...",
"Search notes and excerpts...": "Sök anteckningar och utdrag...",
"{{time}} min left in chapter": "{{time}} min kvar i kapitlet",
"{{count}} pages left in chapter_one": "{{count}} sida kvar i kapitlet",
"{{count}} pages left in chapter_other": "{{count}} sidor kvar i kapitlet",
"On {{current}} of {{total}} page": "På {{current}} av {{total}} sidor",
"Section Title": "Sektionstitel",
"Book Cover": "Bokomslag",
"More Info": "Mer info",
"Parallel Read": "Parallell läsning",
"Disable": "Inaktivera",
"Enable": "Aktivera",
"Exit Parallel Read": "Avsluta parallell läsning",
"Enter Parallel Read": "Starta parallell läsning",
"KOReader Sync": "KOReader-synk",
"Push Progress": "Skicka framsteg",
"Pull Progress": "Hämta framsteg",
"Export Annotations": "Exportera anteckningar",
"Sort TOC by Page": "Sortera innehåll efter sida",
"Edit": "Redigera",
"Go to Library": "Gå till bibliotek",
"Book Menu": "Bokmeny",
"Unpin Sidebar": "Lossa sidofält",
"Pin Sidebar": "Fäst sidofält",
"Search...": "Sök...",
"Search Options": "Sökalternativ",
"Book": "Bok",
"Chapter": "Kapitel",
"Match Case": "Matchning av versaler",
"Match Whole Words": "Matchning av hela ord",
"Match Diacritics": "Matchning av diakritiska tecken",
"Sidebar": "Sidofält",
"Resize Sidebar": "Ändra storlek på sidofält",
"TOC": "Innehåll",
"Bookmark": "Bokmärke",
"Toggle Sidebar": "Visa/dölj sidofält",
"Toggle Translation": "Visa/dölj översättning",
"Disable Translation": "Inaktivera översättning",
"Enable Translation": "Aktivera översättning",
"Translation Disabled": "Översättning inaktiverad",
"Previous Paragraph": "Föregående stycke",
"Previous Sentence": "Föregående mening",
"Pause": "Pausa",
"Play": "Spela",
"Next Sentence": "Nästa mening",
"Next Paragraph": "Nästa stycke",
"Read Aloud": "Läs upp",
"Ready to read aloud": "Redo att läsa upp",
"TTS not supported for PDF": "Uppläsning stöds inte för PDF",
"TTS not supported for this document": "Uppläsning stöds inte för detta dokument",
"No Timeout": "Ingen timeout",
"{{value}} minute": "{{value}} minut",
"{{value}} minutes": "{{value}} minuter",
"{{value}} hour": "{{value}} timme",
"{{value}} hours": "{{value}} timmar",
"Voices for {{lang}}": "Röster för {{lang}}",
"Slow": "Långsam",
"Fast": "Snabb",
"{{engine}}: {{count}} voices_one": "{{engine}}: {{count}} röst",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} röster",
"Zoom Level": "Zoomnivå",
"Zoom Out": "Zooma ut",
"Reset Zoom": "Återställ zoom",
"Zoom In": "Zooma in",
"Zoom Mode": "Zoomläge",
"Single Page": "Enkel sida",
"Auto Spread": "Auto-uppslag",
"Fit Page": "Anpassa sida",
"Fit Width": "Anpassa bredd",
"Separate Cover Page": "Separat omslagssida",
"Scrolled Mode": "Rullningsläge",
"Sign in to Sync": "Logga in för synk",
"Synced at {{time}}": "Synkad {{time}}",
"Never synced": "Aldrig synkad",
"Invert Image In Dark Mode": "Invertera bild i mörkt läge",
"Reading Progress Synced": "Läsframsteg synkat",
"Page {{page}} of {{total}} ({{percentage}}%)": "Sida {{page}} av {{total}} ({{percentage}}%)",
"Current position": "Nuvarande position",
"Approximately page {{page}} of {{total}} ({{percentage}}%)": "Cirka sida {{page}} av {{total}} ({{percentage}}%)",
"Approximately {{percentage}}%": "Cirka {{percentage}}%",
"Delete Your Account?": "Ta bort ditt konto?",
"This action cannot be undone. All your data in the cloud will be permanently deleted.": "Detta kan inte ångras. All din data i molnet raderas permanent.",
"Delete Permanently": "Ta bort permanent",
"Restore Purchase": "Återställ köp",
"Manage Subscription": "Hantera prenumeration",
"Reset Password": "Återställ lösenord",
"Sign Out": "Logga ut",
"Delete Account": "Ta bort konto",
"Upgrade to {{plan}}": "Uppgradera till {{plan}}",
"Complete Your Subscription": "Slutför din prenumeration",
"Coming Soon": "Kommer snart",
"Upgrade to Plus or Pro": "Uppgradera till Plus eller Pro",
"Current Plan": "Nuvarande plan",
"Plan Limits": "Plangränser",
"Failed to delete user. Please try again later.": "Kunde inte ta bort användare. Försök senare.",
"Failed to create checkout session": "Kunde inte skapa betalningssession",
"No purchases found to restore.": "Inga köp att återställa.",
"Failed to restore purchases.": "Kunde inte återställa köp.",
"Failed to manage subscription.": "Kunde inte hantera prenumeration.",
"Failed to load subscription plans.": "Kunde inte ladda prenumerationsplaner.",
"Loading profile...": "Laddar profil...",
"Processing your payment...": "Behandlar din betalning...",
"Please wait while we confirm your subscription.": "Vänta medan vi bekräftar din prenumeration.",
"Payment Processing": "Betalning behandlas",
"Your payment is being processed. This usually takes a few moments.": "Din betalning behandlas. Detta tar vanligtvis några ögonblick.",
"Payment Failed": "Betalning misslyckades",
"We couldn't process your subscription. Please try again or contact support if the issue persists.": "Vi kunde inte behandla din prenumeration. Försök igen eller kontakta support om problemet kvarstår.",
"Back to Profile": "Tillbaka till profil",
"Subscription Successful!": "Prenumeration lyckades!",
"Thank you for your subscription! Your payment has been processed successfully.": "Tack för din prenumeration! Din betalning har behandlats.",
"Email:": "E-post:",
"Plan:": "Plan:",
"Amount:": "Belopp:",
"Subscription ID:": "Prenumerations-ID:",
"Need help? Contact our support team at support@readest.com": "Behöver du hjälp? Kontakta vår support på support@readest.com",
"Free Plan": "Gratisplan",
"month": "månad",
"year": "år",
"Cross-Platform Sync": "Plattformsoberoende synk",
"Seamlessly sync your library, progress, highlights, and notes across all your devices—never lose your place again.": "Synka sömlöst ditt bibliotek, framsteg, markeringar och anteckningar mellan alla dina enheter.",
"Customizable Reading": "Anpassningsbar läsning",
"Personalize every detail with adjustable fonts, layouts, themes, and advanced display settings for the perfect reading experience.": "Anpassa varje detalj med justerbara typsnitt, layouter, teman och avancerade visningsinställningar.",
"AI Read Aloud": "AI-uppläsning",
"Enjoy hands-free reading with natural-sounding AI voices that bring your books to life.": "Njut av handsfree-läsning med naturligt ljudande AI-röster som ger dina böcker liv.",
"AI Translations": "AI-översättningar",
"Translate any text instantly with the power of Google, Azure, or DeepL—understand content in any language.": "Översätt vilken text som helst direkt med Google, Azure eller DeepL.",
"Community Support": "Community-support",
"Connect with fellow readers and get help fast in our friendly community channels.": "Anslut med andra läsare och få snabb hjälp i våra vänliga community-kanaler.",
"Cloud Sync Storage": "Molnsynklagring",
"AI Translations (per day)": "AI-översättningar (per dag)",
"Plus Plan": "Plus-plan",
"Includes All Free Plan Benefits": "Inkluderar alla gratisplanfördelar",
"Unlimited AI Read Aloud Hours": "Obegränsad AI-uppläsning",
"Listen without limits—convert as much text as you like into immersive audio.": "Lyssna obegränsat—konvertera så mycket text du vill till ljud.",
"More AI Translations": "Fler AI-översättningar",
"Unlock enhanced translation capabilities with more daily usage and advanced options.": "Lås upp förbättrad översättning med mer daglig användning och avancerade alternativ.",
"DeepL Pro Access": "DeepL Pro-åtkomst",
"Translate up to 100,000 characters daily with the most accurate translation engine available.": "Översätt upp till 100 000 tecken dagligen med den mest exakta översättningsmotorn.",
"Securely store and access your entire reading collection with up to 5 GB of cloud storage.": "Lagra säkert hela din lässamling med upp till 5 GB molnlagring.",
"Priority Support": "Prioriterad support",
"Enjoy faster responses and dedicated assistance whenever you need help.": "Få snabbare svar och dedikerad hjälp när du behöver det.",
"Pro Plan": "Pro-plan",
"Includes All Plus Plan Benefits": "Inkluderar alla Plus-planfördelar",
"Early Feature Access": "Tidig funktionsåtkomst",
"Be the first to explore new features, updates, and innovations before anyone else.": "Var först att utforska nya funktioner och uppdateringar innan andra.",
"Advanced AI Tools": "Avancerade AI-verktyg",
"Harness powerful AI tools for smarter reading, translation, and content discovery.": "Använd kraftfulla AI-verktyg för smartare läsning, översättning och innehållsupptäckt.",
"Translate up to 500,000 characters daily with the most accurate translation engine available.": "Översätt upp till 500 000 tecken dagligen med den mest exakta översättningsmotorn.",
"Securely store and access your entire reading collection with up to 20 GB of cloud storage.": "Lagra säkert hela din lässamling med upp till 20 GB molnlagring.",
"Version {{version}}": "Version {{version}}",
"Check Update": "Sök uppdatering",
"Already the latest version": "Redan senaste versionen",
"Checking for updates...": "Söker uppdateringar...",
"Error checking for updates": "Fel vid uppdateringskontroll",
"Drop to Import Books": "Släpp för att importera böcker",
"Terms of Service": "Användarvillkor",
"Privacy Policy": "Integritetspolicy",
"ON": "PÅ",
"OFF": "AV",
"Enter book title": "Ange boktitel",
"Subtitle": "Undertitel",
"Enter book subtitle": "Ange underrubrik",
"Enter author name": "Ange författarnamn",
"Series": "Serie",
"Enter series name": "Ange serienamn",
"Series Index": "Serieindex",
"Enter series index": "Ange serieindex",
"Total in Series": "Totalt i serien",
"Enter total books in series": "Ange totalt antal böcker i serien",
"Publisher": "Förlag",
"Enter publisher": "Ange förlag",
"Publication Date": "Publiceringsdatum",
"YYYY or YYYY-MM-DD": "ÅÅÅÅ eller ÅÅÅÅ-MM-DD",
"Language": "Språk",
"Identifier": "Identifierare",
"Subjects": "Ämnen",
"Fiction, Science, History": "Skönlitteratur, Vetenskap, Historia",
"Description": "Beskrivning",
"Enter book description": "Ange bokbeskrivning",
"Change cover image": "Ändra omslagsbild",
"Replace": "Ersätt",
"Remove cover image": "Ta bort omslagsbild",
"Unlock cover": "Lås upp omslag",
"Lock cover": "Lås omslag",
"Auto-Retrieve Metadata": "Auto-hämta metadata",
"Auto": "Auto",
"Auto-Retrieve": "Auto-hämta",
"Unlock all fields": "Lås upp alla fält",
"Unlock All": "Lås upp alla",
"Lock all fields": "Lås alla fält",
"Lock All": "Lås alla",
"Reset": "Återställ",
"Are you sure to delete the selected book?": "Vill du ta bort den valda boken?",
"Are you sure to delete the cloud backup of the selected book?": "Vill du ta bort molnkopian av den valda boken?",
"Are you sure to delete the local copy of the selected book?": "Vill du ta bort den lokala kopian av den valda boken?",
"Edit Metadata": "Redigera metadata",
"Book Details": "Bokdetaljer",
"Unknown": "Okänd",
"Delete Book Options": "Ta bort bokalternativ",
"Remove from Cloud & Device": "Ta bort från moln & enhet",
"Remove from Cloud Only": "Ta bort endast från moln",
"Remove from Device Only": "Ta bort endast från enhet",
"Published": "Publicerad",
"Updated": "Uppdaterad",
"Added": "Tillagd",
"File Size": "Filstorlek",
"No description available": "Ingen beskrivning tillgänglig",
"Locked": "Låst",
"Select Metadata Source": "Välj metadatakälla",
"Keep manual input": "Behåll manuell inmatning",
"Theme Mode": "Temaläge",
"Override Book Color": "Åsidosätt bokfärg",
"Theme Color": "Temafärg",
"Custom": "Anpassad",
"Code Highlighting": "Kodmarkering",
"Enable Highlighting": "Aktivera markering",
"Code Language": "Kodspråk",
"Scroll": "Rullning",
"Continuous Scroll": "Kontinuerlig rullning",
"Overlap Pixels": "Överlappande pixlar",
"Click": "Klick",
"Clicks for Page Flip": "Klick för sidbyte",
"Swap Clicks Area": "Byt klickområde",
"Disable Double Click": "Inaktivera dubbelklick",
"Volume Keys for Page Flip": "Volymknappar för sidbyte",
"Animation": "Animation",
"Paging Animation": "Sidanimation",
"Security": "Säkerhet",
"Allow JavaScript": "Tillåt JavaScript",
"Enable only if you trust the file.": "Aktivera endast om du litar på filen.",
"Font": "Typsnitt",
"Custom Fonts": "Anpassade typsnitt",
"Cancel Delete": "Avbryt borttagning",
"Delete Font": "Ta bort typsnitt",
"Import Font": "Importera typsnitt",
"Tips": "Tips",
"Supported font formats: .ttf, .otf, .woff, .woff2": "Format som stöds: .ttf, .otf, .woff, .woff2",
"Custom fonts can be selected from the Font Face menu": "Anpassade typsnitt kan väljas från typsnittsmeny",
"Global Settings": "Globala inställningar",
"Apply to All Books": "Tillämpa på alla böcker",
"Apply to This Book": "Tillämpa på denna bok",
"Reset Settings": "Återställ inställningar",
"Manage Custom Fonts": "Hantera anpassade typsnitt",
"System Fonts": "Systemtypsnitt",
"Serif Font": "Serif-typsnitt",
"Sans-Serif Font": "Sans-serif-typsnitt",
"Override Book Font": "Åsidosätt boktypsnitt",
"Default Font Size": "Standard teckenstorlek",
"Minimum Font Size": "Minsta teckenstorlek",
"Font Weight": "Typsnittsvikt",
"Font Family": "Typsnittsfamilj",
"Default Font": "Standardtypsnitt",
"CJK Font": "CJK-typsnitt",
"Font Face": "Typsnittssnitt",
"Monospace Font": "Monospace-typsnitt",
"Interface Language": "Gränssnittsspråk",
"Translation": "Översättning",
"Show Source Text": "Visa källtext",
"Translation Service": "Översättningstjänst",
"Translate To": "Översätt till",
"Override Book Layout": "Åsidosätt boklayout",
"Writing Mode": "Skrivläge",
"Default": "Standard",
"Horizontal Direction": "Horisontell riktning",
"Vertical Direction": "Vertikal riktning",
"RTL Direction": "RTL-riktning",
"Border Frame": "Kantram",
"Double Border": "Dubbel kant",
"Border Color": "Kantfärg",
"Paragraph": "Stycke",
"Paragraph Margin": "Styckemarginal",
"Word Spacing": "Ordavstånd",
"Letter Spacing": "Bokstavsavstånd",
"Text Indent": "Textindrag",
"Full Justification": "Full justering",
"Hyphenation": "Avstavning",
"Page": "Sida",
"Top Margin (px)": "Övre marginal (px)",
"Bottom Margin (px)": "Nedre marginal (px)",
"Left Margin (px)": "Vänster marginal (px)",
"Right Margin (px)": "Höger marginal (px)",
"Column Gap (%)": "Kolumnavstånd (%)",
"Maximum Number of Columns": "Max antal kolumner",
"Maximum Column Height": "Max kolumnhöjd",
"Maximum Column Width": "Max kolumnbredd",
"Header & Footer": "Sidhuvud & sidfot",
"Show Header": "Visa sidhuvud",
"Show Footer": "Visa sidfot",
"Show Remaining Time": "Visa återstående tid",
"Show Remaining Pages": "Visa återstående sidor",
"Show Reading Progress": "Visa läsframsteg",
"Reading Progress Style": "Läsframstegsstil",
"Page Number": "Sidnummer",
"Percentage": "Procent",
"Apply also in Scrolled Mode": "Tillämpa även i rullningsläge",
"Screen": "Skärm",
"Orientation": "Orientering",
"Portrait": "Porträtt",
"Landscape": "Landskap",
"Apply": "Tillämpa",
"Custom Content CSS": "Anpassad innehålls-CSS",
"Enter CSS for book content styling...": "Ange CSS för bokinnehållsstil...",
"Custom Reader UI CSS": "Anpassad läsar-UI CSS",
"Enter CSS for reader interface styling...": "Ange CSS för läsargränssnittsstil...",
"Decrease": "Minska",
"Increase": "Öka",
"Layout": "Layout",
"Color": "Färg",
"Behavior": "Beteende",
"Settings Panels": "Inställningspaneler",
"Reset {{settings}}": "Återställ {{settings}}",
"All the world's a stage,\nAnd all the men and women merely players;\nThey have their exits and their entrances,\nAnd one man in his time plays many parts,\nHis acts being seven ages.\n\n— William Shakespeare": "Hela världen är en scen,\nOch alla män och kvinnor bara spelare;\nDe har sina utgångar och entréer,\nOch en man spelar i sin tid många roller,\nHans akter är sju åldrar.\n\n— William Shakespeare",
"(from 'As You Like It', Act II)": "(från 'Som ni behagar', Akt II)",
"Custom Theme": "Anpassat tema",
"Theme Name": "Temanamn",
"Text Color": "Textfärg",
"Background Color": "Bakgrundsfärg",
"Link Color": "Länkfärg",
"Preview": "Förhandsgranska",
"Get Help from the Readest Community": "Få hjälp från Readest-communityn",
"A new version of Readest is available!": "En ny version av Readest finns!",
"Readest {{newVersion}} is available (installed version {{currentVersion}}).": "Readest {{newVersion}} finns tillgänglig (installerad version {{currentVersion}}).",
"Download and install now?": "Ladda ner och installera nu?",
"Downloading {{downloaded}} of {{contentLength}}": "Laddar ner {{downloaded}} av {{contentLength}}",
"Download finished": "Nedladdning klar",
"DOWNLOAD & INSTALL": "LADDA NER & INSTALLERA",
"Changelog": "Ändringslogg",
"Software Update": "Programuppdatering",
"What's New in Readest": "Nyheter i Readest",
"Minimize": "Minimera",
"Maximize or Restore": "Maximera eller återställ",
"Select Files": "Välj filer",
"Select Image": "Välj bild",
"Select Video": "Välj video",
"Select Audio": "Välj ljud",
"Select Fonts": "Välj typsnitt",
"Storage": "Lagring",
"{{percentage}}% of Cloud Sync Space Used.": "{{percentage}}% av molnsynkutrymme använt.",
"Translation Characters": "Översättningstecken",
"{{percentage}}% of Daily Translation Characters Used.": "{{percentage}}% av dagliga översättningstecken använt.",
"Daily translation quota reached. Upgrade your plan to continue using AI translations.": "Daglig översättningskvot nådd. Uppgradera din plan för att fortsätta använda AI-översättningar.",
"LXGW WenKai GB Screen": "LXGW WenKai GB Screen",
"LXGW WenKai TC": "LXGW WenKai TC",
"GuanKiapTsingKhai-T": "GuanKiapTsingKhai-T",
"Source Han Serif CN VF": "Source Han Serif CN VF",
"Huiwen-mincho": "Huiwen-mincho",
"KingHwa_OldSong": "KingHwa_OldSong",
"Google Books": "Google Böcker",
"Open Library": "Open Library",
"Azure Translator": "Azure Translator",
"DeepL": "DeepL",
"Google Translate": "Google Translate",
"Yandex Translate": "Yandex Translate",
"Gray": "Grå",
"Sepia": "Sepia",
"Grass": "Gräs",
"Cherry": "Körsbär",
"Sky": "Himmel",
"Solarized": "Solarized",
"Gruvbox": "Gruvbox",
"Nord": "Nord",
"Contrast": "Kontrast",
"Sunset": "Solnedgång",
"Reveal in Finder": "Visa i Finder",
"Reveal in File Explorer": "Visa i Utforskaren",
"Reveal in Folder": "Visa i mapp",
"Screen Brightness": "Skärmens ljusstyrka",
"Background Image": "Bakgrundsbild",
"Import Image": "Importera bild",
"Opacity": "Opacitet",
"Size": "Storlek",
"Cover": "Omslag",
"Contain": "Innehålla",
"{{number}} pages left in chapter": "{{number}} sidor kvar i kapitlet",
"Device": "Enhet",
"E-Ink Mode": "E-Ink-läge"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}",
"Background Read Aloud": "பின்னணி உரை வாசிப்பு",
"Ready to read aloud": "வாசிக்க தயாராக உள்ளது",
"Read Aloud": "உரை வாசிப்பு"
"Read Aloud": "உரை வாசிப்பு",
"Screen Brightness": "திரை பிரகாசம்",
"Background Image": "பின்னணி படம்",
"Import Image": "புகைப்படம் இறக்குமதி",
"Opacity": "பார்வை",
"Size": "அளவு",
"Cover": "மூடு",
"Contain": "அடங்கும்",
"{{number}} pages left in chapter": "அத்தியாயத்தில் {{number}} பக்கங்கள் மீதமுள்ளன",
"Device": "சாதனம்",
"E-Ink Mode": "E-Ink முறை"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "จำนวนไฟล์: {{size}}",
"Background Read Aloud": "อ่านออกเสียงเบื้องหลัง",
"Ready to read aloud": "พร้อมที่จะอ่านออกเสียง",
"Read Aloud": "อ่านออกเสียง"
"Read Aloud": "อ่านออกเสียง",
"Screen Brightness": "ความสว่างหน้าจอ",
"Background Image": "ภาพพื้นหลัง",
"Import Image": "นำเข้าภาพ",
"Opacity": "ความโปร่งใส",
"Size": "ขนาด",
"Cover": "ปก",
"Contain": "บรรจุ",
"{{number}} pages left in chapter": "เหลือ {{number}} หน้าในบท",
"Device": "อุปกรณ์",
"E-Ink Mode": "โหมด E-Ink"
}
@@ -599,5 +599,15 @@
"File count: {{size}}": "Dosya sayısı: {{size}}",
"Background Read Aloud": "Arka Planda Sesli Okuma",
"Ready to read aloud": "Sesli okumaya hazır",
"Read Aloud": "Sesli Oku"
"Read Aloud": "Sesli Oku",
"Screen Brightness": "Ekran Parlaklığı",
"Background Image": "Arka Plan Görüntüsü",
"Import Image": "Görüntü İçe Aktar",
"Opacity": "Opaklık",
"Size": "Boyut",
"Cover": "Kapak",
"Contain": "İçerir",
"{{number}} pages left in chapter": "Bu bölümde {{number}} sayfa kaldı",
"Device": "Cihaz",
"E-Ink Mode": "E-Ink Modu"
}
@@ -607,5 +607,15 @@
"File count: {{size}}": "Кількість файлів: {{size}}",
"Background Read Aloud": "Озвучування у фоновому режимі",
"Ready to read aloud": "Готовий до читання вголос",
"Read Aloud": "Читати вголос"
"Read Aloud": "Читати вголос",
"Screen Brightness": "Яскравість екрану",
"Background Image": "Фонове зображення",
"Import Image": "Імпортувати зображення",
"Opacity": "Непрозорість",
"Size": "Розмір",
"Cover": "Обкладинка",
"Contain": "Вміщати",
"{{number}} pages left in chapter": "Залишилось {{number}} сторінок у розділі",
"Device": "Пристрій",
"E-Ink Mode": "E-Ink режим"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "Số lượng tệp: {{size}}",
"Background Read Aloud": "Đọc To Nền",
"Ready to read aloud": "Sẵn sàng để đọc to",
"Read Aloud": "Đọc To"
"Read Aloud": "Đọc To",
"Screen Brightness": "Độ sáng màn hình",
"Background Image": "Ảnh nền",
"Import Image": "Nhập ảnh",
"Opacity": "Độ mờ",
"Size": "Kích thước",
"Cover": "Bìa",
"Contain": "Chứa",
"{{number}} pages left in chapter": "Còn {{number}} trang trong chương",
"Device": "Thiết bị",
"E-Ink Mode": "Chế độ E-Ink"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "文件数量: {{size}}",
"Background Read Aloud": "后台语音朗读",
"Ready to read aloud": "语音朗读已就绪",
"Read Aloud": "语音朗读"
"Read Aloud": "语音朗读",
"Screen Brightness": "屏幕亮度",
"Background Image": "背景图片",
"Import Image": "导入图片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "覆盖",
"Contain": "适应",
"{{number}} pages left in chapter": "本章剩余{{number}}页",
"Device": "设备",
"E-Ink Mode": "E-Ink 模式"
}
@@ -595,5 +595,15 @@
"File count: {{size}}": "文件數量: {{size}}",
"Background Read Aloud": "背景朗讀",
"Ready to read aloud": "朗讀準備就緒",
"Read Aloud": "朗讀"
"Read Aloud": "朗讀",
"Screen Brightness": "螢幕亮度",
"Background Image": "背景圖片",
"Import Image": "匯入圖片",
"Opacity": "不透明度",
"Size": "大小",
"Cover": "覆蓋",
"Contain": "適應",
"{{number}} pages left in chapter": "本章剩餘{{number}}頁",
"Device": "設備",
"E-Ink Mode": "E-Ink 模式"
}
+43
View File
@@ -1,5 +1,48 @@
{
"releases": {
"0.9.88": {
"date": "2025-10-18",
"notes": [
"TTS: Fixed issue where the read-aloud indicator disappeared too quickly",
"Layout: Overriding book layout no longer affects explicitly set text alignment",
"Reader: Added localized number display for reading progress in vertical layout",
"Reader: Enhanced swipe responsiveness for smoother page transitions",
"Settings: Added an option to disable animations when using E-Ink mode"
]
},
"0.9.87": {
"date": "2025-10-16",
"notes": [
"Reader: Improved chapter detection with better chapter title recognition",
"Reader: Enhanced compatibility for parsing more footnote formats",
"Theme: Fixed auto theme switching in new reader windows",
"TTS: Improved scrolling accuracy to highlighted text during read-aloud",
"TTS: Fixed crash issues on certain Android systems"
]
},
"0.9.86": {
"date": "2025-10-14",
"notes": [
"Reader: Added support for per-book background image customization",
"Reader: Added option to override book language metadata for improved hyphenation accuracy",
"Sync: Readest sync in KOReader can now be triggered using gesture controls",
"Sync: Fixed a crash in KOReader that occurred when syncing progress without an open book",
"Sync: Improved upload performance with concurrent upload support"
]
},
"0.9.85": {
"date": "2025-10-14",
"notes": [
"TTS: Improved media session control compatibility across more Android systems",
"TTS: Made the TTS icon less distracting during read-aloud mode",
"Settings: Added an option to adjust screen brightness while reading",
"Settings: Added support for custom background images",
"PDF: Fixed an issue where scrolling and panning didnt work correctly when zoomed in",
"Android: Added adaptive launcher icon for better system integration",
"Reader: Added keyboard shortcuts for toggling bookmarks",
"Reader: Annotations are now rendered instantly when opening files"
]
},
"0.9.82": {
"date": "2025-10-01",
"notes": [
@@ -115,11 +115,22 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity>
<receiver
android:name="androidx.media.session.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:name="com.readest.native_tts.MediaForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:name="com.readest.native_tts.MediaPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -69,6 +69,11 @@ class LockScreenOrientationRequestArgs {
var orientation: String? = null
}
@InvokeArg
class SetScreenBrightnessRequestArgs {
var brightness: Double? = null // 0.0 to 1.0
}
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
@@ -76,7 +81,7 @@ interface KeyDownInterceptor {
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.MANAGE_EXTERNAL_STORAGE], alias = "manageStorage")
Permission(strings = [Manifest.permission.MANAGE_EXTERNAL_STORAGE], alias = "manageStorage"),
]
)
class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
@@ -415,6 +420,44 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve(ret)
}
@Command
fun get_screen_brightness(invoke: Invoke) {
val ret = JSObject()
try {
val brightness = Settings.System.getInt(
activity.contentResolver,
Settings.System.SCREEN_BRIGHTNESS
)
val normalizedBrightness = brightness / 255.0
ret.put("brightness", normalizedBrightness)
} catch (e: Exception) {
ret.put("error", e.message)
ret.put("brightness", -1.0)
}
invoke.resolve(ret)
}
@Command
fun set_screen_brightness(invoke: Invoke) {
val args = invoke.parseArgs(SetScreenBrightnessRequestArgs::class.java)
val ret = JSObject()
try {
val brightness = (args.brightness ?: 0.5).toFloat()
if (brightness < 0.0 || brightness > 1.0) {
invoke.reject("Brightness must be between 0.0 and 1.0")
return
}
val layoutParams = activity.window.attributes
layoutParams.screenBrightness = brightness
activity.window.attributes = layoutParams
ret.put("success", true)
} catch (e: Exception) {
ret.put("success", false)
ret.put("error", e.message)
}
invoke.resolve(ret)
}
@Command
fun request_manage_storage_permission(invoke: Invoke) {
val ret = JSObject()
@@ -15,6 +15,8 @@ const COMMANDS: &[&str] = &[
"iap_restore_purchases",
"get_system_color_scheme",
"get_safe_area_insets",
"get_screen_brightness",
"set_screen_brightness",
"request_manage_storage_permission",
"checkPermissions",
"requestPermissions",
@@ -49,6 +49,10 @@ class LockScreenOrientationRequestArgs: Decodable {
let orientation: String?
}
class SetScreenBrightnessRequestArgs: Decodable {
let brightness: Float?
}
struct InitializeRequest: Decodable {
let publicKey: String?
}
@@ -539,6 +543,28 @@ class NativeBridgePlugin: Plugin {
let colorScheme = (userInterfaceStyle == .dark) ? "dark" : "light"
invoke.resolve(["colorScheme": colorScheme])
}
@objc public func get_screen_brightness(_ invoke: Invoke) {
let brightness = UIScreen.main.brightness
invoke.resolve(["brightness": brightness])
}
@objc public func set_screen_brightness(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(SetScreenBrightnessRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
}
let brightness = args.brightness ?? 0.5
if brightness < 0.0 || brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
DispatchQueue.main.async {
UIScreen.main.brightness = CGFloat(brightness)
}
invoke.resolve(["success": true])
}
}
@_cdecl("init_plugin_native_bridge")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-screen-brightness"
description = "Enables the get_screen_brightness command without any pre-configured scope."
commands.allow = ["get_screen_brightness"]
[[permission]]
identifier = "deny-get-screen-brightness"
description = "Denies the get_screen_brightness command without any pre-configured scope."
commands.deny = ["get_screen_brightness"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-screen-brightness"
description = "Enables the set_screen_brightness command without any pre-configured scope."
commands.allow = ["set_screen_brightness"]
[[permission]]
identifier = "deny-set-screen-brightness"
description = "Denies the set_screen_brightness command without any pre-configured scope."
commands.deny = ["set_screen_brightness"]
@@ -20,6 +20,8 @@ Default permissions for the plugin
- `allow-iap-restore-purchases`
- `allow-get-system-color-scheme`
- `allow-get-safe-area-insets`
- `allow-get-screen-brightness`
- `allow-set-screen-brightness`
- `allow-request-manage-storage-permission`
- `allow-checkPermissions`
- `allow-requestPermissions`
@@ -166,6 +168,32 @@ Denies the get_safe_area_insets command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-get-screen-brightness`
</td>
<td>
Enables the get_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-get-screen-brightness`
</td>
<td>
Denies the get_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-get-status-bar-height`
</td>
@@ -478,6 +506,32 @@ Denies the request_manage_storage_permission command without any pre-configured
<tr>
<td>
`native-bridge:allow-set-screen-brightness`
</td>
<td>
Enables the set_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-set-screen-brightness`
</td>
<td>
Denies the set_screen_brightness command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-set-system-ui-visibility`
</td>
@@ -17,6 +17,8 @@ permissions = [
"allow-iap-restore-purchases",
"allow-get-system-color-scheme",
"allow-get-safe-area-insets",
"allow-get-screen-brightness",
"allow-set-screen-brightness",
"allow-request-manage-storage-permission",
"allow-checkPermissions",
"allow-requestPermissions",
@@ -354,6 +354,18 @@
"const": "deny-get-safe-area-insets",
"markdownDescription": "Denies the get_safe_area_insets command without any pre-configured scope."
},
{
"description": "Enables the get_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "allow-get-screen-brightness",
"markdownDescription": "Enables the get_screen_brightness command without any pre-configured scope."
},
{
"description": "Denies the get_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "deny-get-screen-brightness",
"markdownDescription": "Denies the get_screen_brightness command without any pre-configured scope."
},
{
"description": "Enables the get_status_bar_height command without any pre-configured scope.",
"type": "string",
@@ -498,6 +510,18 @@
"const": "deny-request-manage-storage-permission",
"markdownDescription": "Denies the request_manage_storage_permission command without any pre-configured scope."
},
{
"description": "Enables the set_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "allow-set-screen-brightness",
"markdownDescription": "Enables the set_screen_brightness command without any pre-configured scope."
},
{
"description": "Denies the set_screen_brightness command without any pre-configured scope.",
"type": "string",
"const": "deny-set-screen-brightness",
"markdownDescription": "Denies the set_screen_brightness command without any pre-configured scope."
},
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -523,10 +547,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-request-manage-storage-permission`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
}
]
}
@@ -127,6 +127,21 @@ pub(crate) async fn get_safe_area_insets<R: Runtime>(
app.native_bridge().get_safe_area_insets()
}
#[command]
pub(crate) async fn get_screen_brightness<R: Runtime>(
app: AppHandle<R>,
) -> Result<GetScreenBrightnessResponse> {
app.native_bridge().get_screen_brightness()
}
#[command]
pub(crate) async fn set_screen_brightness<R: Runtime>(
app: AppHandle<R>,
payload: SetScreenBrightnessRequest,
) -> Result<SetScreenBrightnessResponse> {
app.native_bridge().set_screen_brightness(payload)
}
#[command]
pub(crate) async fn request_manage_storage_permission<R: Runtime>(
app: AppHandle<R>,
@@ -107,6 +107,17 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_screen_brightness(
&self,
_payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn request_manage_storage_permission(
&self,
) -> crate::Result<RequestManageStoragePermissionResponse> {
@@ -53,6 +53,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::iap_restore_purchases,
commands::get_system_color_scheme,
commands::get_safe_area_insets,
commands::get_screen_brightness,
commands::set_screen_brightness,
commands::request_manage_storage_permission,
])
.setup(|app, api| {
@@ -170,6 +170,25 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn get_screen_brightness(&self) -> crate::Result<GetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("get_screen_brightness", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn set_screen_brightness(
&self,
payload: SetScreenBrightnessRequest,
) -> crate::Result<SetScreenBrightnessResponse> {
self.0
.run_mobile_plugin("set_screen_brightness", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn request_manage_storage_permission(
&self,
@@ -168,6 +168,25 @@ pub struct GetSafeAreaInsetsResponse {
pub right: f64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GetScreenBrightnessResponse {
pub brightness: f64,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetScreenBrightnessRequest {
pub brightness: f64, // 0.0 to 1.0
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetScreenBrightnessResponse {
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestManageStoragePermissionResponse {
@@ -33,9 +33,8 @@ android {
}
dependencies {
implementation("androidx.media3:media3-common:1.2.0")
implementation("androidx.media3:media3-session:1.2.0")
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media:media:1.7.1")
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.7.0")
@@ -0,0 +1,269 @@
package com.readest.native_tts
import com.readest.native_tts.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.graphics.Bitmap
import android.media.AudioManager
import android.media.AudioManager.OnAudioFocusChangeListener
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.media.MediaBrowserServiceCompat
import androidx.media.session.MediaButtonReceiver
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import app.tauri.plugin.JSObject
class MediaPlaybackService : MediaBrowserServiceCompat() {
private var mediaSession: MediaSessionCompat? = null
private lateinit var player: ExoPlayer
private lateinit var stateBuilder: PlaybackStateCompat.Builder
private lateinit var audioManager: AudioManager
private val afChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
Log.i("MediaPlaybackService", "Audio focus changed: $focusChange, $player.isPlaying")
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
player.volume = 1.0f
if (!player.isPlaying) player.play()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
player.volume = 0.3f
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (player.isPlaying) player.pause()
}
}
}
companion object {
private const val CHANNEL_ID = "media2_playback_channel"
private const val NOTIFICATION_ID = 1002
private const val MEDIA_ROOT_ID = "media_root_id"
var pluginEventTrigger: ((String, JSObject) -> Unit)? = null
var currentTitle: String = "Read Aloud"
var currentArtist: String = "Reading your content"
var currentArtwork: Bitmap? = null
}
override fun onCreate() {
super.onCreate()
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val result = audioManager.requestAudioFocus(
afChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.d("MediaPlaybackService", "Audio focus granted")
} else {
Log.w("MediaPlaybackService", "Failed to gain audio focus")
}
player = ExoPlayer.Builder(this).build()
mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply {
stateBuilder = PlaybackStateCompat.Builder().setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PLAY_PAUSE or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
setPlaybackState(stateBuilder.build())
setCallback(SessionCallback())
setSessionToken(sessionToken)
isActive = true
}
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
updatePlaybackState()
}
override fun onPlaybackStateChanged(playbackState: Int) {
updatePlaybackState()
}
})
val mediaItem = MediaItem.fromUri("asset:///silence.mp3")
player.setMediaItem(mediaItem)
player.repeatMode = Player.REPEAT_MODE_ONE
player.prepare()
player.playWhenReady = true
showNotification(PlaybackStateCompat.STATE_PLAYING)
}
private inner class SessionCallback : MediaSessionCompat.Callback() {
override fun onPlay() {
player.play()
pluginEventTrigger?.invoke("media-session-play", JSObject())
updatePlaybackState()
}
override fun onPause() {
player.pause()
pluginEventTrigger?.invoke("media-session-pause", JSObject())
updatePlaybackState()
}
override fun onSkipToNext() {
player.seekTo(0)
pluginEventTrigger?.invoke("media-session-next", JSObject())
}
override fun onSkipToPrevious() {
player.seekTo(0)
pluginEventTrigger?.invoke("media-session-previous", JSObject())
}
}
private fun updatePlaybackState() {
val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, player.currentPosition, 1f).build()
)
showNotification(state)
}
private fun showNotification(playbackState: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL_ID, "Media Controls", NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
startForeground(NOTIFICATION_ID, buildNotification(playbackState))
}
private fun buildNotification(playbackState: Int): Notification {
val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply {
setContentTitle(currentTitle)
setContentText(currentArtist)
setLargeIcon(currentArtwork)
setContentIntent(mediaSession!!.controller.sessionActivity)
setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this@MediaPlaybackService, PlaybackStateCompat.ACTION_STOP))
setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
setSmallIcon(R.drawable.notification_icon)
addAction(
android.R.drawable.ic_media_previous,
"Previous",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
if (playbackState == PlaybackStateCompat.STATE_PLAYING) {
addAction(
android.R.drawable.ic_media_pause,
"Pause",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_PAUSE
)
)
} else {
addAction(
android.R.drawable.ic_media_play,
"Play",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_PLAY
)
)
}
addAction(
android.R.drawable.ic_media_next,
"Next",
MediaButtonReceiver.buildMediaButtonPendingIntent(
this@MediaPlaybackService,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession?.sessionToken)
.setShowActionsInCompactView(0, 1, 2)
)
}
return builder.build()
}
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
return BrowserRoot(MEDIA_ROOT_ID, null)
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
result.sendResult(null)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
if (intent?.action == "UPDATE_METADATA") {
currentTitle = intent.getStringExtra("title") ?: currentTitle
currentArtist = intent.getStringExtra("artist") ?: currentArtist
val newArtwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra("artwork", Bitmap::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra("artwork")
}
if (newArtwork != null) {
currentArtwork = newArtwork
}
val metadataBuilder = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork)
mediaSession?.setMetadata(metadataBuilder.build())
showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED)
} else if (intent?.action == "UPDATE_PLAYBACK_STATE") {
val isPlaying = intent.getBooleanExtra("playing", false)
val position = intent.getLongExtra("position", 0L) // in milliseconds
val duration = intent.getLongExtra("duration", 0L) // in milliseconds
if (isPlaying && !player.isPlaying) {
player.play()
} else if (!isPlaying && player.isPlaying) {
player.pause()
}
player.seekTo(position)
val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED
mediaSession?.setPlaybackState(
stateBuilder.setState(state, position, 1f).build()
)
showNotification(state)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
player.release()
mediaSession?.release()
}
}
@@ -11,13 +11,7 @@ import android.speech.tts.Voice
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.ForwardingPlayer
import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.ExoPlayer
import androidx.core.content.ContextCompat
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
@@ -92,8 +86,8 @@ class UpdateMediaSessionMetadataArgs {
@InvokeArg
class UpdateMediaSessionStateArgs {
var playing: Boolean? = null
var position: Double? = null
var duration: Double? = null
var position: Int? = null // in milliseconds
var duration: Int? = null // in milliseconds
}
@InvokeArg
@@ -106,78 +100,6 @@ class SetMediaSessionActiveArgs {
var foregroundServiceText: String? = null
}
class MediaForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "tts_media_playback_channel"
const val ACTION_START = "START_FOREGROUND_SERVICE"
const val ACTION_STOP = "STOP_FOREGROUND_SERVICE"
var pluginInstance: NativeTTSPlugin? = null
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForegroundService()
}
ACTION_STOP -> {
stopForegroundService()
}
}
return START_STICKY // Restart if killed
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
NativeTTSPlugin.NOTIFICATION_TITLE,
NotificationManager.IMPORTANCE_MIN
).apply {
description = NativeTTSPlugin.NOTIFICATION_TEXT
setShowBadge(false)
setSound(null, null)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun startForegroundService() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(NativeTTSPlugin.FOREGROUND_SERVICE_TITLE)
.setContentText(NativeTTSPlugin.FOREGROUND_SERVICE_TEXT)
.setSmallIcon(android.R.drawable.ic_media_play)
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
startForeground(NOTIFICATION_ID, notification)
Log.d("MediaForegroundService", "Foreground service started")
}
private fun stopForegroundService() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.d("MediaForegroundService", "Foreground service stopped")
}
override fun onDestroy() {
super.onDestroy()
Log.d("MediaForegroundService", "Service destroyed")
}
}
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "postNotification")
@@ -203,18 +125,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private var player: Player? = null
private var mediaSession: MediaSession? = null
private var isMediaSessionActive = false
private var isForegroundServiceRunning = false
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
MediaForegroundService.pluginInstance = this
}
@Command
fun init(invoke: Invoke) {
coroutineScope.launch {
@@ -509,95 +421,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
}
}
@UnstableApi
private class WebViewPlayer(player: Player) : ForwardingPlayer(player) {
var eventTrigger: ((String, JSObject) -> Unit)? = null
override fun play() {
eventTrigger?.invoke("media-session-play", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun pause() {
eventTrigger?.invoke("media-session-pause", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun seekToNext() {
eventTrigger?.invoke("media-session-next", JSObject())
}
override fun seekToPrevious() {
eventTrigger?.invoke("media-session-previous", JSObject())
}
}
private fun initializeMediaSession() {
var basePlayer = ExoPlayer.Builder(activity).build()
val webViewPlayer = WebViewPlayer(basePlayer)
webViewPlayer.eventTrigger = { event, data -> trigger(event, data) }
player = webViewPlayer
mediaSession = MediaSession.Builder(activity, player!!)
.setCallback(object : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val builder = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
builder.setAvailablePlayerCommands(
Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
)
return builder.build()
}
})
.build()
}
private fun deinitializeMediaSession() {
mediaSession?.release()
mediaSession = null
player?.release()
player = null
}
private fun startForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
Log.d(TAG, "Foreground service started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service", e)
}
}
private fun stopForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_STOP
}
activity.stopService(intent)
Log.d(TAG, "Foreground service stopped")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop foreground service", e)
}
}
private suspend fun loadArtworkFromUrl(urlString: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
@@ -624,50 +447,26 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
}
}
private var currentArtworkByteArray: ByteArray? = null
@Command
fun update_media_session_metadata(invoke: Invoke) {
val args = invoke.parseArgs(UpdateMediaSessionMetadataArgs::class.java)
val title = args.title ?: ""
val artist = args.artist ?: ""
val album = args.album ?: ""
val artworkUrl = args.artwork ?: ""
coroutineScope.launch {
try {
val metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.apply {
if (artworkUrl.isNotEmpty()) {
loadArtworkFromUrl(artworkUrl)?.let { bitmap ->
java.io.ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
currentArtworkByteArray = stream.toByteArray()
}
}
}
currentArtworkByteArray?.let {
setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
}
val mediaItem: MediaItem = MediaItem.Builder()
.setMediaId("silent_media")
.setUri("data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQQAAAAAAA==")
.setMediaMetadata(metadataBuilder.build())
.build()
player?.setMediaItems(listOf(mediaItem, mediaItem))
player?.prepare()
player?.playWhenReady = true
player?.setRepeatMode(Player.REPEAT_MODE_ALL)
val artworkBitmap = args.artwork?.let { loadArtworkFromUrl(it) }
val intent = Intent(activity, MediaPlaybackService::class.java).apply {
action = "UPDATE_METADATA"
putExtra("title", title)
putExtra("artist", artist)
putExtra("album", album)
putExtra("artwork", artworkBitmap)
}
activity.startService(intent)
invoke.resolve()
} catch (e: Exception) {
Log.e("NativeBridgePlugin", "Failed to update metadata: ${e.message}")
invoke.reject("Failed to update metadata: ${e.message}")
}
}
@@ -677,15 +476,17 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
fun update_media_session_state(invoke: Invoke) {
var args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java)
val isPlaying = args.playing ?: false
val position = args.position ?: 0.0
val position = args.position ?: 0
val duration = args.duration ?: 0
try {
player?.let { player ->
if (position > 0) {
player.seekTo((position * 1000).toLong())
}
player.playWhenReady = isPlaying
val intent = Intent(activity, MediaPlaybackService::class.java).apply {
action = "UPDATE_PLAYBACK_STATE"
putExtra("playing", isPlaying)
putExtra("position", position)
putExtra("duration", duration)
}
activity.startService(intent)
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Failed to update playback state: ${e.message}")
@@ -696,7 +497,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
fun set_media_session_active(invoke: Invoke) {
var args = invoke.parseArgs(SetMediaSessionActiveArgs::class.java)
val active = args.active ?: true
val keepAppInForeground = args.keepAppInForeground ?: false
args.notificationTitle?.let { NOTIFICATION_TITLE = it }
args.notificationText?.let { NOTIFICATION_TEXT = it }
@@ -704,41 +504,26 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
args.foregroundServiceText?.let { FOREGROUND_SERVICE_TEXT = it }
try {
if (active && !isMediaSessionActive) {
initializeMediaSession()
if (keepAppInForeground) {
startForegroundService()
isForegroundServiceRunning = true
}
isMediaSessionActive = true
Log.d(TAG, "Media session activated with foreground service")
} else if (!active && isMediaSessionActive) {
deinitializeMediaSession()
if (isForegroundServiceRunning) {
stopForegroundService()
isForegroundServiceRunning = false
}
isMediaSessionActive = false
Log.d(TAG, "Media session deactivated and foreground service stopped")
val intent = Intent(activity, MediaPlaybackService::class.java)
if (active) {
MediaPlaybackService.pluginEventTrigger = { event, data -> trigger(event, data) }
MediaPlaybackService.currentTitle = FOREGROUND_SERVICE_TITLE
MediaPlaybackService.currentArtist = FOREGROUND_SERVICE_TEXT
ContextCompat.startForegroundService(activity, intent)
} else {
activity.stopService(intent)
MediaPlaybackService.pluginEventTrigger = null
}
invoke.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to set media session active state: ${e.message}")
invoke.reject("Failed to set media session active state: ${e.message}")
}
}
fun destroy() {
try {
if (isMediaSessionActive) {
if (isForegroundServiceRunning) {
stopForegroundService()
isForegroundServiceRunning = false
}
isMediaSessionActive = false
}
deinitializeMediaSession()
val intent = Intent(activity, MediaPlaybackService::class.java)
activity.stopService(intent)
coroutineScope.cancel()
textToSpeech?.shutdown()
@@ -747,8 +532,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
MediaForegroundService.pluginInstance = null
Log.d(TAG, "Plugin destroyed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during plugin destruction", e)
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+2 -2
View File
@@ -17,8 +17,8 @@
"default-src": "'self' 'unsafe-inline' blob: data: customprotocol: asset: http://asset.localhost ipc: http://ipc.localhost",
"connect-src": "'self' blob: data: 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 https://*.cloudflarestorage.com https://translate.googleapis.com https://translate.toil.cc https://*.microsofttranslator.com https://edge.microsoft.com https://*.googleusercontent.com",
"img-src": "'self' blob: data: asset: http://asset.localhost https://*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://chinese-fonts-cdn.netlify.app https://cdnjs.cloudflare.com",
"frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
},
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import * as React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdDelete, MdOpenInNew, MdOutlineCancel, MdInfoOutline } from 'react-icons/md';
import { LuFolderPlus } from 'react-icons/lu';
import { PiPlus } from 'react-icons/pi';
@@ -14,7 +14,8 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useTranslation } from '@/hooks/useTranslation';
import { navigateToLibrary, navigateToReader, showReaderWindow } from '@/utils/nav';
import { formatAuthors, formatTitle } from '@/utils/book';
import { createBookFilter, createBookSorter } from '../utils/libraryUtils';
import { formatTitle } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isMd5 } from '@/utils/md5';
@@ -30,11 +31,12 @@ interface BookshelfProps {
isSelectAll: boolean;
isSelectNone: boolean;
handleImportBooks: () => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null };
}
@@ -49,6 +51,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
handlePushLibrary,
booksTransferProgress,
}) => {
const _ = useTranslation();
@@ -75,7 +78,18 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const { setCurrentBookshelf, setLibrary } = useLibraryStore();
const { setSelectedBooks, getSelectedBooks, toggleSelectedBook } = useLibraryStore();
const allBookshelfItems = generateBookshelfItems(libraryBooks);
const bookFilter = useMemo(() => createBookFilter(queryTerm), [queryTerm]);
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const bookSorter = useMemo(() => createBookSorter(sortBy, uiLanguage), [sortBy, uiLanguage]);
const filteredBooks = useMemo(() => {
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
}, [libraryBooks, queryTerm, bookFilter]);
const allBookshelfItems = useMemo(() => {
return generateBookshelfItems(filteredBooks);
}, [filteredBooks]);
const autofocusRef = useAutoFocus<HTMLDivElement>();
@@ -104,8 +118,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
} else {
setCurrentBookshelf(allBookshelfItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraryBooks, navBooksGroup]);
}, [allBookshelfItems, navBooksGroup, setCurrentBookshelf]);
useEffect(() => {
const group = searchParams?.get('group') || '';
@@ -165,13 +178,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
params.delete('group');
navigateToLibrary(router, `${params.toString()}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, libraryBooks, showGroupingModal]);
}, [router, settings, searchParams, allBookshelfItems, showGroupingModal]);
const toggleSelection = useCallback((id: string) => {
toggleSelectedBook(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const toggleSelection = useCallback(
(id: string) => {
toggleSelectedBook(id);
},
[toggleSelectedBook],
);
const openSelectedBooks = () => {
handleSetSelectMode(false);
@@ -195,7 +209,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const getBooksToDelete = () => {
const booksToDelete: Book[] = [];
bookIdsToDelete.forEach((id) => {
for (const book of libraryBooks.filter((book) => book.hash === id || book.groupId === id)) {
for (const book of filteredBooks.filter((book) => book.hash === id || book.groupId === id)) {
if (book && !book.deletedAt) {
booksToDelete.push(book);
}
@@ -205,9 +219,14 @@ const Bookshelf: React.FC<BookshelfProps> = ({
};
const confirmDelete = async () => {
for (const book of getBooksToDelete()) {
handleBookDelete(book);
const books = getBooksToDelete();
const concurrency = 4;
for (let i = 0; i < books.length; i += concurrency) {
const batch = books.slice(i, i + concurrency);
await Promise.all(batch.map((book) => handleBookDelete(book, false)));
}
handlePushLibrary();
setSelectedBooks([]);
setShowDeleteAlert(false);
setShowSelectModeActions(true);
@@ -231,50 +250,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowDeleteAlert(true);
};
const bookFilter = (item: Book, queryTerm: string) => {
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
const bookSorter = (a: Book, b: Book) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
const filteredBookshelfItems = currentBookshelfItems
.filter((item) => {
if ('name' in item) return item.books.some((book) => bookFilter(book, queryTerm || ''));
else if (queryTerm) return bookFilter(item, queryTerm);
return true;
})
.sort((a, b) => {
const uiLanguage = localStorage?.getItem('i18nextLng') || '';
const sortedBookshelfItems = useMemo(() => {
const sortOrderMultiplier = sortOrder === 'asc' ? 1 : -1;
const currentBookshelfItems = navBooksGroup ? navBooksGroup.books : allBookshelfItems;
return currentBookshelfItems.sort((a, b) => {
if (sortBy === 'updated') {
return (
(new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * sortOrderMultiplier
@@ -289,14 +268,13 @@ const Bookshelf: React.FC<BookshelfProps> = ({
return 0;
}
});
}, [sortOrder, sortBy, uiLanguage, navBooksGroup, allBookshelfItems, bookSorter]);
useEffect(() => {
if (isSelectMode) {
setShowSelectModeActions(true);
if (isSelectAll) {
setSelectedBooks(
filteredBookshelfItems.map((item) => ('hash' in item ? item.hash : item.id)),
);
setSelectedBooks(filteredBooks.map((item) => item.hash));
} else if (isSelectNone) {
setSelectedBooks([]);
}
@@ -305,7 +283,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowSelectModeActions(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSelectMode, isSelectAll, isSelectNone]);
}, [isSelectMode, isSelectAll, isSelectNone, filteredBooks]);
useEffect(() => {
eventDispatcher.on('delete-books', handleDeleteBooksIntent);
@@ -330,7 +308,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
role='main'
aria-label={_('Bookshelf')}
>
{filteredBookshelfItems.map((item) => (
{sortedBookshelfItems.map((item) => (
<BookshelfItem
key={`library-item-${'hash' in item ? item.hash : item.id}`}
item={item}
@@ -77,9 +77,9 @@ interface BookshelfItemProps {
transferProgress: number | null;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
toggleSelection: (hash: string) => void;
handleBookUpload: (book: Book) => Promise<boolean>;
handleBookDownload: (book: Book) => Promise<boolean>;
handleBookDelete: (book: Book) => Promise<boolean>;
handleBookUpload: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
}
@@ -109,7 +109,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar bg-base-200 z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
@@ -150,7 +150,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input rounded-badge bg-base-300/50 h-9 w-full pl-10 pr-10 sm:h-7',
'input rounded-badge bg-base-300/45 h-9 w-full pl-10 pr-10 sm:h-7',
'font-sans text-sm font-light',
'border-none focus:outline-none focus:ring-0',
)}
+57 -18
View File
@@ -38,6 +38,7 @@ import { useOpenWithBooks } from '@/hooks/useOpenWithBooks';
import { SelectedFile, useFileSelector } from '@/hooks/useFileSelector';
import { lockScreenOrientation } from '@/utils/bridge';
import {
tauriHandleClose,
tauriHandleSetAlwaysOnTop,
tauriHandleToggleFullScreen,
tauriQuitApp,
@@ -79,6 +80,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { safeAreaInsets: insets, isRoundedWindow } = useThemeStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { isFontLayoutSettingsDialogOpen } = useSettingsStore();
const { setFontLayoutSettingsDialogOpen } = useSettingsStore();
const [loading, setLoading] = useState(false);
const isInitiating = useRef(false);
const [libraryLoaded, setLibraryLoaded] = useState(false);
@@ -116,11 +118,22 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
await tauriHandleToggleFullScreen();
}
},
onCloseWindow: async () => {
if (isTauriAppPlatform()) {
await tauriHandleClose();
}
},
onQuitApp: async () => {
if (isTauriAppPlatform()) {
await tauriQuitApp();
}
},
onOpenFontLayoutSettings: () => {
setFontLayoutSettingsDialogOpen(true);
},
onOpenBooks: () => {
handleImportBooks();
},
});
useEffect(() => {
@@ -406,41 +419,56 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const importBooks = async (files: SelectedFile[]) => {
setLoading(true);
const failedFiles = [];
const { library } = useLibraryStore.getState();
const failedImports: Array<{ filename: string; errorMessage: string }> = [];
const errorMap: [string, string][] = [
['No chapters detected.', _('No chapters detected.')],
['Failed to parse EPUB.', _('Failed to parse the EPUB file.')],
['Unsupported format.', _('This book format is not supported.')],
];
const { library } = useLibraryStore.getState();
for (const selectedFile of files) {
const processFile = async (selectedFile: SelectedFile) => {
const file = selectedFile.file || selectedFile.path;
if (!file) continue;
if (!file) return;
try {
const book = await appService?.importBook(file, library);
setLibrary([...library]);
if (user && book && !book.uploadedAt && settings.autoUpload) {
console.log('Uploading book:', book.title);
handleBookUpload(book);
handleBookUpload(book, false);
}
} catch (error) {
const filename = typeof file === 'string' ? file : file.name;
const baseFilename = getFilename(filename);
failedFiles.push(baseFilename);
const errorMessage =
error instanceof Error
? errorMap.find(([substring]) => error.message.includes(substring))?.[1] || ''
: '';
eventDispatcher.dispatch('toast', {
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(failedFiles),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
failedImports.push({ filename: baseFilename, errorMessage });
console.error('Failed to import book:', filename, error);
}
};
const concurrency = 4;
for (let i = 0; i < files.length; i += concurrency) {
const batch = files.slice(i, i + concurrency);
await Promise.all(batch.map(processFile));
}
pushLibrary();
if (failedImports.length > 0) {
const filenames = failedImports.map((f) => f.filename);
const errorMessage = failedImports.find((f) => f.errorMessage)?.errorMessage || '';
eventDispatcher.dispatch('toast', {
message:
_('Failed to import book(s): {{filenames}}', {
filenames: listFormater(false).format(filenames),
}) + (errorMessage ? `\n${errorMessage}` : ''),
type: 'error',
});
}
setLibrary([...library]);
appService?.saveLibraryBooks(library);
setLoading(false);
};
@@ -455,13 +483,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}, 500);
const handleBookUpload = useCallback(
async (book: Book) => {
async (book: Book, syncBooks = true) => {
try {
await appService?.uploadBook(book, (progress) => {
updateBookTransferProgress(book.hash, progress);
});
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
await updateBook(envConfig, book);
pushLibrary();
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
@@ -471,6 +504,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
return true;
} catch (err) {
setBooksTransferProgress((prev) => {
const updated = { ...prev };
delete updated[book.hash];
return updated;
});
if (err instanceof Error) {
if (err.message.includes('Not authenticated') && settings.keepLogin) {
settings.keepLogin = false;
@@ -528,7 +566,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
);
const handleBookDelete = (deleteAction: DeleteAction) => {
return async (book: Book) => {
return async (book: Book, syncBooks = true) => {
const deletionMessages = {
both: _('Book deleted: {{title}}', { title: book.title }),
cloud: _('Deleted cloud backup of the book: {{title}}', { title: book.title }),
@@ -542,7 +580,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
try {
await appService?.deleteBook(book, deleteAction);
await updateBook(envConfig, book);
pushLibrary();
if (syncBooks) pushLibrary();
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 2000,
@@ -696,6 +734,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
/>
</div>
</OverlayScrollbarsComponent>
@@ -0,0 +1,38 @@
import { Book } from '@/types/book';
import { formatAuthors, formatTitle } from '@/utils/book';
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true;
if (item.deletedAt) return false;
const searchTerm = new RegExp(queryTerm, 'i');
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description))
);
};
export const createBookSorter = (sortBy: string, uiLanguage: string) => (a: Book, b: Book) => {
switch (sortBy) {
case 'title':
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
case 'author':
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
case 'updated':
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case 'created':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case 'format':
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
@@ -11,6 +11,7 @@ import { BookNote } from '@/types/book';
import { uniqueId } from '@/utils/misc';
import Button from '@/components/Button';
import { getCurrentPage } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
interface BookmarkTogglerProps {
bookKey: string;
@@ -21,17 +22,21 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, setBookmarkRibbonVisibility } = useReaderStore();
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const bookData = getBookData(bookKey)!;
const { getProgress, getViewState, setBookmarkRibbonVisibility } = useReaderStore();
const [isBookmarked, setIsBookmarked] = useState(false);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const toggleBookmark = () => {
const bookData = getBookData(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
if (!bookData || !config || !progress) return;
const { booknotes: bookmarks = [] } = config;
const { location: cfi, range } = progress;
if (!cfi) return;
const isBookmarked = getViewState(bookKey)?.ribbonVisible;
if (!isBookmarked) {
setIsBookmarked(true);
const text = range?.startContainer.textContent?.slice(0, 128) || '';
@@ -78,6 +83,19 @@ const BookmarkToggler: React.FC<BookmarkTogglerProps> = ({ bookKey }) => {
}
};
useEffect(() => {
const handleBookmarkToggle = (e: CustomEvent) => {
const { bookKey: eventBookKey } = e.detail;
if (eventBookKey !== bookKey) return;
toggleBookmark();
};
eventDispatcher.on('toggle-bookmark', handleBookmarkToggle);
return () => {
eventDispatcher.off('toggle-bookmark', handleBookmarkToggle);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
useEffect(() => {
const { booknotes = [] } = config || {};
const { location: cfi } = progress || {};
@@ -14,7 +14,7 @@ import SettingsDialog from '@/components/settings/SettingsDialog';
import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import FooterBar from './FooterBar';
import FooterBar from './footerbar/FooterBar';
import ProgressInfoView from './ProgressInfo';
import Ribbon from './Ribbon';
import Annotator from './annotator/Annotator';
@@ -15,14 +15,17 @@ import { usePagination } from '../hooks/usePagination';
import { useFoliateEvents } from '../hooks/useFoliateEvents';
import { useProgressSync } from '../hooks/useProgressSync';
import { useProgressAutoSave } from '../hooks/useProgressAutoSave';
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useAutoFocus } from '@/hooks/useAutoFocus';
import { useTranslation } from '@/hooks/useTranslation';
import { useEinkMode } from '@/hooks/useEinkMode';
import { useKOSync } from '../hooks/useKOSync';
import {
applyFixedlayoutStyles,
applyImageStyle,
applyTranslationStyle,
getStyles,
keepTextAlignment,
transformStylesheet,
} from '@/utils/style';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
@@ -42,6 +45,7 @@ import { getMaxInlineSize } from '@/utils/config';
import { getDirFromUILanguage } from '@/utils/rtl';
import { isCJKLang } from '@/utils/lang';
import { isTauriAppPlatform } from '@/services/environment';
import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import { lockScreenOrientation } from '@/utils/bridge';
import { useTextTranslation } from '../hooks/useTextTranslation';
@@ -73,6 +77,8 @@ const FoliateViewer: React.FC<{
const { getViewState, getViewSettings, setViewSettings } = useReaderStore();
const { getParallels } = useParallelViewStore();
const { getBookData } = useBookDataStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
@@ -116,15 +122,17 @@ const FoliateViewer: React.FC<{
detail.data = Promise.resolve(detail.data)
.then((data) => {
const viewSettings = getViewSettings(bookKey);
const bookData = getBookData(bookKey);
if (viewSettings && detail.type === 'text/css')
return transformStylesheet(width, height, data);
if (viewSettings && detail.type === 'application/xhtml+xml') {
if (viewSettings && bookData && detail.type === 'application/xhtml+xml') {
const ctx = {
bookKey,
viewSettings,
primaryLanguage: bookData.book?.primaryLanguage,
content: data,
transformers: ['punctuation', 'footnote', 'language'],
};
transformers: ['punctuation', 'footnote', 'whitespace', 'language'],
} as TransformContext;
return Promise.resolve(transformContent(ctx));
}
return data;
@@ -145,14 +153,19 @@ const FoliateViewer: React.FC<{
const writingDir = viewRef.current?.renderer.setStyles && getDirection(detail.doc);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey)!;
viewSettings.vertical =
const newVertical =
writingDir?.vertical || viewSettings.writingMode.includes('vertical') || false;
viewSettings.rtl =
const newRtl =
writingDir?.rtl ||
getDirFromUILanguage() === 'rtl' ||
viewSettings.writingMode.includes('rl') ||
false;
setViewSettings(bookKey, { ...viewSettings });
if (viewSettings.vertical !== newVertical || viewSettings.rtl !== newRtl) {
viewSettings.vertical = newVertical;
viewSettings.rtl = newRtl;
setViewSettings(bookKey, { ...viewSettings });
}
mountAdditionalFonts(detail.doc, isCJKLang(bookData.book?.primaryLanguage));
@@ -166,6 +179,8 @@ const FoliateViewer: React.FC<{
applyImageStyle(detail.doc);
keepTextAlignment(detail.doc);
removeTabIndex(detail.doc);
// Inline scripts in tauri platforms are not executed by default
@@ -178,6 +193,13 @@ const FoliateViewer: React.FC<{
manageSyntaxHighlighting(detail.doc, viewSettings);
}
setTimeout(() => {
const booknotes = config.booknotes || [];
booknotes
.filter((item) => !item.deletedAt && item.type === 'annotation' && item.style)
.forEach((annotation) => viewRef.current?.addAnnotation(annotation));
}, 100);
if (!detail.doc.isEventListenersAdded) {
// listened events in iframes are posted to the main window
// and then used by useMouseEvent and useTouchEvent
@@ -280,7 +302,7 @@ const FoliateViewer: React.FC<{
book.transformTarget?.addEventListener('load', (event: Event) => {
const { detail } = event as CustomEvent;
if (detail.isScript) {
detail.allowScript = viewSettings.allowScript ?? false;
detail.allow = viewSettings.allowScript ?? false;
}
});
const viewWidth = appService?.isMobile ? screen.width : window.innerWidth;
@@ -293,6 +315,7 @@ const FoliateViewer: React.FC<{
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
const animated = viewSettings.animated!;
const eink = viewSettings.isEink!;
const maxColumnCount = viewSettings.maxColumnCount!;
const maxInlineSize = getMaxInlineSize(viewSettings);
const maxBlockSize = viewSettings.maxBlockSize!;
@@ -305,6 +328,14 @@ const FoliateViewer: React.FC<{
} else {
view.renderer.removeAttribute('animated');
}
if (appService?.isAndroidApp) {
if (eink) {
view.renderer.setAttribute('eink', '');
} else {
view.renderer.removeAttribute('eink');
}
applyEinkMode(eink);
}
if (bookDoc?.rendition?.layout === 'pre-paginated') {
view.renderer.setAttribute('zoom', viewSettings.zoomMode);
view.renderer.setAttribute('spread', viewSettings.spreadMode);
@@ -388,6 +419,17 @@ const FoliateViewer: React.FC<{
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings.customFonts, envConfig]);
useEffect(() => {
if (!viewSettings) return;
applyBackgroundTexture(envConfig, viewSettings);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
viewSettings?.backgroundTextureId,
viewSettings?.backgroundOpacity,
viewSettings?.backgroundSize,
applyBackgroundTexture,
]);
useEffect(() => {
if (viewRef.current && viewRef.current.renderer) {
doubleClickDisabled.current = !!viewSettings?.disableDoubleClick;
@@ -1,422 +0,0 @@
import React, { useEffect } from 'react';
import clsx from 'clsx';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { FaHeadphones } from 'react-icons/fa6';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { PiNotePencil as NoteIcon } from 'react-icons/pi';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { saveViewSettings } from '@/helpers/viewSettings';
import { eventDispatcher } from '@/utils/event';
import { viewPagination } from '../hooks/usePagination';
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
import TTSControl from './tts/TTSControl';
interface FooterBarProps {
bookKey: string;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = React.useState('');
const sliderHeight = useResponsiveSize(28);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
const marginIconSize = useResponsiveSize(20);
const view = getView(bookKey);
const config = getConfig(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const viewState = getViewState(bookKey);
const handleProgressChange = (value: number) => {
view?.goToFraction(value / 100.0);
};
const handleFontSizeChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
};
const handleMarginChange = (value: number) => {
const viewSettings = getViewSettings(bookKey)!;
const marginPx = Math.round((value / 100) * 88);
const gapPercent = Math.round((value / 100) * 10);
viewSettings.marginTopPx = marginPx;
viewSettings.marginBottomPx = marginPx / 2;
viewSettings.marginLeftPx = marginPx / 2;
viewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (viewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
};
const handleLineHeightChange = (value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / 10);
};
const handleGoPrevPage = () => {
viewPagination(view, viewSettings, 'left');
};
const handleGoNextPage = () => {
viewPagination(view, viewSettings, 'right');
};
const handleGoPrevSection = () => {
if (view?.renderer.prevSection) {
view?.renderer.prevSection();
}
};
const handleGoNextSection = () => {
if (view?.renderer.nextSection) {
view?.renderer.nextSection();
}
};
const handleGoBack = () => {
view?.history.back();
};
const handleGoForward = () => {
view?.history.forward();
};
const handleSpeakText = async () => {
if (!view || !progress || !viewState) return;
if (viewState.ttsEnabled) {
eventDispatcher.dispatch('tts-stop', { bookKey });
} else {
eventDispatcher.dispatch('tts-speak', { bookKey });
}
};
const handleSetActionTab = (tab: string) => {
setActionTab(actionTab === tab ? '' : tab);
if (tab === 'tts') {
setHoveredBookKey('');
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (config && config.viewSettings) {
config.viewSettings.sideBarTab = 'toc';
setConfig(bookKey, config);
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (config && config.viewSettings) {
config.viewSettings.sideBarTab = 'annotations';
setConfig(bookKey, config);
}
}
};
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const getMarginProgressValue = (marginPx: number, gapPercent: number) => {
return (marginPx / 88 + gapPercent / 10) * 50;
};
const isVisible = hoveredBookKey === bookKey;
const ttsEnabled = viewState?.ttsEnabled;
const progressInfo = bookFormat === 'PDF' ? section : pageinfo;
const progressValid = !!progressInfo;
const progressFraction =
progressValid && progressInfo?.total > 0
? (progressInfo!.current + 1) / progressInfo!.total || 0
: 0;
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<>
<div
role='none'
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
<div
role='group'
aria-label={_('Footer Bar')}
className={clsx(
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
// See: https://github.com/readest/readest/issues/1716
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
// show scroll bar when vertical and scrolled in desktop
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? `pointer-events-auto translate-y-0 opacity-100`
: `pointer-events-none translate-y-full opacity-0 sm:translate-y-0`,
)}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
>
{/* Mobile footer bar */}
<div
className={clsx(
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Reading Progress')}
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressFraction * 100)}%`}
initialValue={progressValid ? progressFraction * 100 : 0}
onChange={(e) => handleProgressChange(e)}
/>
</div>
<div className='flex w-full items-center justify-between gap-x-6'>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
label={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
label={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
label={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
label={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
<div
className={clsx(
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
)}
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px',
}}
>
<Slider
label={_('Font Size')}
initialValue={viewSettings?.defaultFontSize ?? 16}
bubbleLabel={`${viewSettings?.defaultFontSize ?? 16}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={8}
max={30}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Page Margin')}
initialValue={getMarginProgressValue(
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
label={_('Line Spacing')}
initialValue={(viewSettings?.lineHeight ?? 1.6) * 10}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={8}
max={24}
onChange={handleLineHeightChange}
/>
</div>
</div>
<div
className={clsx(
'bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden',
)}
style={{
paddingBottom: isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px',
}}
>
<Button
label={_('Table of Contents')}
icon={<TOCIcon size={tocIconSize} className='' />}
onClick={() => handleSetActionTab('toc')}
/>
<Button
label={_('Notes')}
icon={<NoteIcon className='' />}
onClick={() => handleSetActionTab('note')}
/>
<Button
label={_('Reading Progress')}
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => handleSetActionTab('progress')}
/>
<Button
label={_('Font & Layout')}
icon={
<FontIcon
size={fontIconSize}
className={clsx(actionTab === 'font' && 'text-blue-500')}
/>
}
onClick={() => handleSetActionTab('font')}
/>
<Button
label={_('Speak')}
icon={<TTSIcon className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => handleSetActionTab('tts')}
/>
</div>
{/* Desktop / Pad footer bar */}
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{
bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px',
}}
>
<Button
icon={viewSettings?.rtl ? <RiArrowRightDoubleLine /> : <RiArrowLeftDoubleLine />}
onClick={viewSettings?.rtl ? handleGoNextSection : handleGoPrevSection}
label={viewSettings?.rtl ? _('Next Section') : _('Previous Section')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowRightSLine /> : <RiArrowLeftSLine />}
onClick={viewSettings?.rtl ? handleGoNextPage : handleGoPrevPage}
label={viewSettings?.rtl ? _('Next Page') : _('Previous Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoForwardLine /> : <RiArrowGoBackLine />}
onClick={handleGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowGoBackLine /> : <RiArrowGoForwardLine />}
onClick={handleGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
{progressValid && (
<span
title={_('Reading Progress')}
aria-label={_('Reading Progress') + ': ' + `${Math.round(progressFraction * 100)}%`}
className='mx-2 text-center text-sm'
>
<span aria-hidden='true'>{`${Math.round(progressFraction * 100)}%`}</span>
</span>
)}
<input
type='range'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
aria-label={_('Jump to Location')}
value={progressValid ? progressFraction * 100 : 0}
onChange={(e) =>
handleProgressChange(parseInt((e.target as HTMLInputElement).value, 10))
}
/>
<Button
icon={<FaHeadphones className={ttsEnabled ? 'text-blue-500' : ''} />}
onClick={handleSpeakText}
label={_('Speak')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftSLine /> : <RiArrowRightSLine />}
onClick={viewSettings?.rtl ? handleGoPrevPage : handleGoNextPage}
label={viewSettings?.rtl ? _('Previous Page') : _('Next Page')}
/>
<Button
icon={viewSettings?.rtl ? <RiArrowLeftDoubleLine /> : <RiArrowRightDoubleLine />}
onClick={viewSettings?.rtl ? handleGoPrevSection : handleGoNextSection}
label={viewSettings?.rtl ? _('Previous Section') : _('Next Section')}
/>
</div>
</div>
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
export default FooterBar;
@@ -61,10 +61,7 @@ const HintInfo: React.FC<SectionInfoProps> = ({
return (
<>
<div
className={clsx(
'absolute left-0 right-0 top-0 z-10',
hintMessage ? 'bg-base-100' : 'bg-transparent',
)}
className={clsx('absolute left-0 right-0 top-0 z-10', hintMessage ? '' : 'bg-transparent')}
style={{
height: `${topInset}px`,
}}
@@ -72,7 +69,7 @@ const HintInfo: React.FC<SectionInfoProps> = ({
<div
className={clsx(
'hintinfo absolute flex items-center justify-end overflow-hidden ps-2',
hintMessage ? 'bg-base-100' : 'bg-transparent',
hintMessage ? '' : 'bg-transparent',
isVertical ? 'writing-vertical-rl' : 'top-0 h-[44px]',
isScrolled
? isVertical
@@ -6,7 +6,7 @@ import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { formatReadingProgress } from '@/utils/progress';
import { formatNumber, formatProgress } from '@/utils/progress';
interface PageInfoProps {
bookKey: string;
@@ -40,24 +40,34 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
const isVertical = viewSettings.vertical;
const { progressStyle: readingProgressStyle } = viewSettings;
const formatTemplate =
const template =
readingProgressStyle === 'fraction'
? isVertical
? '{current} · {total}'
: '{current} / {total}'
: '{percent}%';
const pageProgress = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = bookData?.isFixedLayout
? formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate)
: formatReadingProgress(pageProgress?.current, pageProgress?.total, formatTemplate);
const lang = localStorage?.getItem('i18nextLng') || '';
const localize = isVertical && lang.toLowerCase().startsWith('zh');
const progress = bookData?.isFixedLayout ? section : pageinfo;
const progressInfo = formatProgress(progress?.current, progress?.total, template, localize, lang);
const timeLeft = timeinfo
? _('{{time}} min left in chapter', { time: Math.round(timeinfo.section) })
? _('{{time}} min left in chapter', {
time: formatNumber(Math.round(timeinfo.section), localize, lang),
})
: '';
const { page = 0, pages = 0 } = view?.renderer || {};
const pageLeft =
pages - 1 > page ? _('{{count}} pages left in chapter', { count: pages - 1 - page }) : '';
pages - 1 > page
? localize
? _('{{number}} pages left in chapter', {
number: formatNumber(pages - page - 1, localize, lang),
})
: _('{{count}} pages left in chapter', {
count: pages - page - 1,
})
: '';
return (
<div
@@ -68,10 +78,10 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
isScrolled && !isVertical && 'bg-base-100',
)}
aria-label={[
pageProgress
progress
? _('On {{current}} of {{total}} page', {
current: pageProgress.current + 1,
total: pageProgress.total,
current: progress.current + 1,
total: progress.total,
})
: '',
timeLeft,
@@ -99,7 +109,7 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
<div
aria-hidden='true'
className={clsx(
'flex items-center justify-center',
'flex items-center justify-between',
isVertical ? 'h-full' : 'h-[52px] w-full',
)}
>
@@ -108,7 +118,11 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
) : viewSettings.showRemainingPages ? (
<span className='text-start'>{pageLeft}</span>
) : null}
{viewSettings.showProgressInfo && <span className='ms-auto text-end'>{progressInfo}</span>}
{viewSettings.showProgressInfo && (
<span className={clsx('text-end', isVertical ? 'mt-auto' : 'ms-auto')}>
{progressInfo}
</span>
)}
</div>
</div>
);
@@ -68,8 +68,8 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
}
: {
top: `${topInset}px`,
insetInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: `calc(100% - ${contentInsets.left + contentInsets.right}px)`,
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left}px)`,
width: '100%',
}
}
>
@@ -0,0 +1,157 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useMemo } from 'react';
import { PiSun, PiMoon } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { themes } from '@/styles/themes';
import { debounce } from '@/utils/debounce';
import Slider from '@/components/Slider';
const SCREEN_BRIGHTNESS_LIMITS = {
MIN: 0,
MAX: 100,
DEFAULT: 50,
} as const;
interface ColorPanelProps {
actionTab: string;
bottomOffset: string;
}
export const ColorPanel: React.FC<ColorPanelProps> = ({ actionTab, bottomOffset }) => {
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings, setSettings, saveSettings } = useSettingsStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor } = useThemeStore();
const [screenBrightnessValue, setScreenBrightnessValue] = React.useState(
settings.screenBrightness >= 0 ? settings.screenBrightness : SCREEN_BRIGHTNESS_LIMITS.DEFAULT,
);
useEffect(() => {
if (!appService?.isMobileApp) return;
if (settings.screenBrightness >= 0) return;
getScreenBrightness().then((brightness) => {
if (brightness >= 0.0 && brightness <= 1.0) {
const screenBrightness = Math.round(brightness * 100);
setScreenBrightnessValue(screenBrightness);
}
});
}, [appService, settings, setSettings, getScreenBrightness]);
const debouncedSetScreenBrightness = useMemo(
() =>
debounce(async (value: number) => {
settings.screenBrightness = value;
setSettings(settings);
saveSettings(envConfig, settings);
await setScreenBrightness(value / 100);
}, 100),
[envConfig, settings, setSettings, saveSettings, setScreenBrightness],
);
const handleScreenBrightnessChange = useCallback(
async (value: number) => {
if (!appService?.isMobileApp) return;
setScreenBrightnessValue(value);
debouncedSetScreenBrightness(value);
},
[appService, debouncedSetScreenBrightness],
);
const cycleThemeMode = () => {
const nextMode = themeMode === 'auto' ? 'light' : themeMode === 'light' ? 'dark' : 'auto';
setThemeMode(nextMode);
};
const classes = clsx(
'footerbar-color-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'color'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
);
return (
<div className={classes} style={{ bottom: bottomOffset }}>
{appService?.hasScreenBrightness && (
<Slider
label={_('Screen Brightness')}
initialValue={screenBrightnessValue}
bubbleLabel={`${screenBrightnessValue}`}
minIcon={<PiSun size={16} />}
maxIcon={<PiSun size={24} />}
onChange={handleScreenBrightnessChange}
min={SCREEN_BRIGHTNESS_LIMITS.MIN}
max={SCREEN_BRIGHTNESS_LIMITS.MAX}
/>
)}
<div className='w-full'>
<div className='flex items-center justify-between p-2'>
<span className='text-sm font-medium'>{_('Color')}</span>
</div>
<div
className='flex gap-3 overflow-x-auto p-2'
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<style>{`
.theme-scroll::-webkit-scrollbar {
display: none;
}
`}</style>
{themes.map(({ name, label, colors }) => (
<button
key={name}
onClick={() => setThemeColor(name)}
className={clsx(
'flex flex-shrink-0 flex-col items-center justify-center rounded-lg p-3 transition-all',
'h-[40px] min-w-[80px]',
themeColor === name
? 'ring-primary ring-offset-base-200 ring-2 ring-offset-2'
: 'hover:opacity-80',
)}
style={{
backgroundColor: isDarkMode ? colors.dark['base-100'] : colors.light['base-100'],
color: isDarkMode ? colors.dark['base-content'] : colors.light['base-content'],
}}
>
<span className='text-xs font-medium'>{_(label)}</span>
</button>
))}
<button
onClick={() => cycleThemeMode()}
className={clsx(
'flex flex-shrink-0 flex-col items-center justify-center rounded-lg p-3 transition-all',
'h-[40px] min-w-[80px]',
themeMode === 'dark'
? 'ring-primary ring-offset-base-200 ring-2 ring-offset-2'
: 'hover:opacity-80',
)}
style={{
backgroundColor: (themes.find((t) => t.name === themeColor) || themes[0]!).colors
.dark['base-100'],
color: (themes.find((t) => t.name === themeColor) || themes[0]!).colors.dark[
'base-content'
],
}}
>
{themeMode === 'light' ? (
<PiSun size={20} />
) : themeMode === 'dark' ? (
<PiMoon size={20} />
) : (
<TbSunMoon size={20} />
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,134 @@
import React, { useCallback, useEffect } from 'react';
import { FaHeadphones } from 'react-icons/fa6';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { getNavigationIcon, getNavigationLabel, getNavigationHandler } from './utils';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { FooterBarChildProps } from './types';
import Button from '@/components/Button';
const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
bookKey,
gridInsets,
progressValid,
progressFraction,
navigationHandlers,
onSpeakText,
}) => {
const _ = useTranslation();
const { getView, getViewState, getViewSettings } = useReaderStore();
const view = getView(bookKey);
const viewState = getViewState(bookKey);
const viewSettings = getViewSettings(bookKey);
const [progressValue, setProgressValue] = React.useState(
progressValid ? progressFraction * 100 : 0,
);
useEffect(() => {
if (progressValid) {
setProgressValue(progressFraction * 100);
}
}, [progressValid, progressFraction]);
const handleProgressChange = useCallback(
(value: number) => {
setProgressValue(value);
navigationHandlers.onProgressChange(value);
},
[navigationHandlers],
);
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
return (
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
style={{ bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px' }}
>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowLeftDoubleLine />,
<RiArrowRightDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevSection,
navigationHandlers.onNextSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Section'), _('Next Section'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowLeftSLine />, <RiArrowRightSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevPage,
navigationHandlers.onNextPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Page'), _('Next Page'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowGoBackLine />, <RiArrowGoForwardLine />)}
onClick={navigationHandlers.onGoBack}
label={_('Go Back')}
disabled={!view?.history.canGoBack}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowGoForwardLine />, <RiArrowGoBackLine />)}
onClick={navigationHandlers.onGoForward}
label={_('Go Forward')}
disabled={!view?.history.canGoForward}
/>
{progressValid && (
<span
title={_('Reading Progress')}
aria-label={`${_('Reading Progress')}: ${Math.round(progressFraction * 100)}%`}
className='mx-2 text-center text-sm'
>
<span aria-hidden='true'>{`${Math.round(progressFraction * 100)}%`}</span>
</span>
)}
<input
type='range'
className='text-base-content mx-2 min-w-0 flex-1'
min={0}
max={100}
aria-label={_('Jump to Location')}
value={progressValue}
onChange={(e) => handleProgressChange(parseInt(e.target.value, 10))}
/>
<Button
icon={<FaHeadphones className={viewState?.ttsEnabled ? 'text-blue-500' : ''} />}
onClick={onSpeakText!}
label={_('Speak')}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowRightSLine />, <RiArrowLeftSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextPage,
navigationHandlers.onPrevPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Page'), _('Previous Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowRightDoubleLine />,
<RiArrowLeftDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextSection,
navigationHandlers.onPrevSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Section'), _('Previous Section'))}
/>
</div>
);
};
export default DesktopFooterBar;
@@ -0,0 +1,140 @@
import clsx from 'clsx';
import React, { useCallback } from 'react';
import { TbBoxMargin } from 'react-icons/tb';
import { RxLineHeight } from 'react-icons/rx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { saveViewSettings } from '@/helpers/viewSettings';
import Slider from '@/components/Slider';
const FONT_SIZE_LIMITS = {
MIN: 8,
MAX: 30,
DEFAULT: 16,
} as const;
const LINE_HEIGHT_LIMITS = {
MIN: 8,
MAX: 24,
DEFAULT: 16,
MULTIPLIER: 10,
} as const;
const MARGIN_CONSTANTS = {
MAX_MARGIN_PX: 88,
MAX_GAP_PERCENT: 10,
MARGIN_RATIO: 50,
} as const;
interface FontLayoutPanelProps {
bookKey: string;
actionTab: string;
bottomOffset: string;
marginIconSize: number;
}
export const FontLayoutPanel: React.FC<FontLayoutPanelProps> = ({
bookKey,
actionTab,
bottomOffset,
marginIconSize,
}) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey);
const view = getView(bookKey);
const handleFontSizeChange = useCallback(
(value: number) => {
saveViewSettings(envConfig, bookKey, 'defaultFontSize', value);
},
[envConfig, bookKey],
);
const handleMarginChange = useCallback(
(value: number) => {
const currentViewSettings = getViewSettings(bookKey);
if (!currentViewSettings) return;
const { MAX_MARGIN_PX, MAX_GAP_PERCENT } = MARGIN_CONSTANTS;
const marginPx = Math.round((value / 100) * MAX_MARGIN_PX);
const gapPercent = Math.round((value / 100) * MAX_GAP_PERCENT);
currentViewSettings.marginTopPx = marginPx;
currentViewSettings.marginBottomPx = marginPx / 2;
currentViewSettings.marginLeftPx = marginPx / 2;
currentViewSettings.marginRightPx = marginPx / 2;
saveViewSettings(envConfig, bookKey, 'gapPercent', gapPercent, false, false);
view?.renderer.setAttribute('margin', `${marginPx}px`);
view?.renderer.setAttribute('gap', `${gapPercent}%`);
if (currentViewSettings?.scrolled) {
view?.renderer.setAttribute('flow', 'scrolled');
}
},
[envConfig, bookKey, view, getViewSettings],
);
const handleLineHeightChange = useCallback(
(value: number) => {
saveViewSettings(envConfig, bookKey, 'lineHeight', value / LINE_HEIGHT_LIMITS.MULTIPLIER);
},
[envConfig, bookKey],
);
const getMarginProgressValue = useCallback((marginPx: number, gapPercent: number) => {
const { MAX_MARGIN_PX, MAX_GAP_PERCENT, MARGIN_RATIO } = MARGIN_CONSTANTS;
return (marginPx / MAX_MARGIN_PX + gapPercent / MAX_GAP_PERCENT) * MARGIN_RATIO;
}, []);
const classes = clsx(
'footerbar-font-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'font'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
);
return (
<div className={classes} style={{ bottom: bottomOffset }}>
<Slider
label={_('Font Size')}
initialValue={viewSettings?.defaultFontSize ?? FONT_SIZE_LIMITS.DEFAULT}
bubbleLabel={`${viewSettings?.defaultFontSize ?? FONT_SIZE_LIMITS.DEFAULT}`}
minLabel='A'
maxLabel='A'
minClassName='text-xs'
maxClassName='text-base'
onChange={handleFontSizeChange}
min={FONT_SIZE_LIMITS.MIN}
max={FONT_SIZE_LIMITS.MAX}
/>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Page Margin')}
initialValue={getMarginProgressValue(
viewSettings?.marginTopPx ?? 44,
viewSettings?.gapPercent ?? 5,
)}
bubbleElement={<TbBoxMargin size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
step={10}
onChange={handleMarginChange}
/>
<Slider
label={_('Line Spacing')}
initialValue={(viewSettings?.lineHeight ?? 1.6) * LINE_HEIGHT_LIMITS.MULTIPLIER}
bubbleElement={<RxLineHeight size={marginIconSize} />}
minLabel={_('Small')}
maxLabel={_('Large')}
min={LINE_HEIGHT_LIMITS.MIN}
max={LINE_HEIGHT_LIMITS.MAX}
onChange={handleLineHeightChange}
/>
</div>
</div>
);
};
@@ -0,0 +1,213 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { FooterBarProps, NavigationHandlers, FooterBarChildProps } from './types';
import { debounce } from '@/utils/debounce';
import { viewPagination } from '../../hooks/usePagination';
import MobileFooterBar from './MobileFooterBar';
import DesktopFooterBar from './DesktopFooterBar';
import TTSControl from '../tts/TTSControl';
const FooterBar: React.FC<FooterBarProps> = ({
bookKey,
bookFormat,
section,
pageinfo,
isHoveredAnim,
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
const { getConfig, setConfig } = useBookDataStore();
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
const { getView, getViewState, getProgress, getViewSettings } = useReaderStore();
const { isSideBarVisible, setSideBarVisible } = useSidebarStore();
const [actionTab, setActionTab] = useState('');
const view = getView(bookKey);
const config = getConfig(bookKey);
const viewState = getViewState(bookKey);
const progress = getProgress(bookKey);
const viewSettings = getViewSettings(bookKey);
const isVisible = hoveredBookKey === bookKey;
const progressInfo = useMemo(
() => (bookFormat === 'PDF' ? section : pageinfo),
[bookFormat, section, pageinfo],
);
const progressValid = !!progressInfo;
const progressFraction = useMemo(() => {
if (!progressValid || !progressInfo?.total || progressInfo.total <= 0) {
return 0;
}
return (progressInfo.current + 1) / progressInfo.total;
}, [progressValid, progressInfo]);
const handleProgressChange = useMemo(
() =>
debounce((value: number) => {
view?.goToFraction(value / 100.0);
}, 100),
[view],
);
const handleGoPrevPage = useCallback(() => {
viewPagination(view, viewSettings, 'left');
}, [view, viewSettings]);
const handleGoNextPage = useCallback(() => {
viewPagination(view, viewSettings, 'right');
}, [view, viewSettings]);
const handleGoPrevSection = useCallback(() => {
view?.renderer.prevSection?.();
}, [view]);
const handleGoNextSection = useCallback(() => {
view?.renderer.nextSection?.();
}, [view]);
const handleGoBack = useCallback(() => {
view?.history.back();
}, [view]);
const handleGoForward = useCallback(() => {
view?.history.forward();
}, [view]);
const handleSpeakText = useCallback(async () => {
if (!view || !progress || !viewState) return;
const eventType = viewState.ttsEnabled ? 'tts-stop' : 'tts-speak';
eventDispatcher.dispatch(eventType, { bookKey });
}, [view, progress, viewState, bookKey]);
const handleSetActionTab = useCallback(
(tab: string) => {
setActionTab((prevTab) => (prevTab === tab ? '' : tab));
if (tab === 'tts') {
if (viewState?.ttsEnabled) {
setHoveredBookKey('');
}
handleSpeakText();
} else if (tab === 'toc') {
setHoveredBookKey('');
if (config?.viewSettings) {
config.viewSettings.sideBarTab = 'toc';
setConfig(bookKey, config);
}
setSideBarVisible(true);
} else if (tab === 'note') {
setHoveredBookKey('');
setSideBarVisible(true);
if (config?.viewSettings) {
config.viewSettings.sideBarTab = 'annotations';
setConfig(bookKey, config);
}
}
},
[
config,
bookKey,
viewState?.ttsEnabled,
setConfig,
setSideBarVisible,
setHoveredBookKey,
handleSpeakText,
],
);
useEffect(() => {
if (hoveredBookKey !== bookKey) {
setActionTab('');
}
}, [hoveredBookKey, bookKey]);
const navigationHandlers: NavigationHandlers = useMemo(
() => ({
onPrevPage: handleGoPrevPage,
onNextPage: handleGoNextPage,
onPrevSection: handleGoPrevSection,
onNextSection: handleGoNextSection,
onGoBack: handleGoBack,
onGoForward: handleGoForward,
onProgressChange: handleProgressChange,
}),
[
handleGoPrevPage,
handleGoNextPage,
handleGoPrevSection,
handleGoNextSection,
handleGoBack,
handleGoForward,
handleProgressChange,
],
);
const commonProps: FooterBarChildProps = {
bookKey,
gridInsets,
actionTab,
progressValid,
progressFraction,
navigationHandlers,
onSetActionTab: handleSetActionTab,
onSpeakText: handleSpeakText,
};
const containerClasses = clsx(
'footer-bar shadow-xs bottom-0 z-10 flex w-full flex-col',
'sm:h-[52px] sm:justify-center',
'sm:bg-base-100 border-base-300/50 border-t sm:border-none',
'transition-[opacity,transform] duration-300',
appService?.isAndroidApp && window.innerWidth < 640 ? 'fixed' : 'absolute',
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
);
return (
<>
{/* Hover trigger area */}
<div
role='none'
className={clsx(
'absolute bottom-0 left-0 z-10 hidden w-full sm:flex sm:h-[52px]',
viewSettings?.vertical && viewSettings?.scrolled && 'sm:!bottom-3 sm:!h-7',
)}
onMouseEnter={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onTouchStart={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
/>
{/* Main footer container */}
<div
role='group'
aria-label={_('Footer Bar')}
className={containerClasses}
dir={viewSettings?.rtl ? 'rtl' : 'ltr'}
onFocus={() => !appService?.isMobile && setHoveredBookKey(bookKey)}
onMouseLeave={() => window.innerWidth >= 640 && setHoveredBookKey('')}
>
<MobileFooterBar {...commonProps} />
<DesktopFooterBar {...commonProps} />
</div>
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
);
};
export default FooterBar;
@@ -0,0 +1,51 @@
import React from 'react';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { FooterBarChildProps } from './types';
import { NavigationPanel } from './NavigationPanel';
import { FontLayoutPanel } from './FontLayoutPanel';
import { ColorPanel } from './ColorPanel';
import { NavigationBar } from './NavigationBar';
const MobileFooterBar: React.FC<FooterBarChildProps> = ({
bookKey,
gridInsets,
actionTab,
progressValid,
progressFraction,
navigationHandlers,
onSetActionTab,
}) => {
const isMobile = window.innerWidth < 640 || window.innerHeight < 640;
const sliderHeight = useResponsiveSize(28);
const marginIconSize = useResponsiveSize(20);
const bottomOffset = isMobile ? `${gridInsets.bottom * 0.33 + 64}px` : '64px';
const navPadding = isMobile ? `${gridInsets.bottom * 0.33 + 16}px` : '0px';
return (
<>
<ColorPanel actionTab={actionTab} bottomOffset={bottomOffset} />
<NavigationPanel
actionTab={actionTab}
progressFraction={progressFraction}
progressValid={progressValid}
navigationHandlers={navigationHandlers}
bottomOffset={bottomOffset}
sliderHeight={sliderHeight}
/>
<FontLayoutPanel
bookKey={bookKey}
actionTab={actionTab}
bottomOffset={bottomOffset}
marginIconSize={marginIconSize}
/>
<NavigationBar
bookKey={bookKey}
actionTab={actionTab}
navPadding={navPadding}
onSetActionTab={onSetActionTab!}
/>
</>
);
};
export default MobileFooterBar;
@@ -0,0 +1,66 @@
import clsx from 'clsx';
import React from 'react';
import { IoIosList as TOCIcon } from 'react-icons/io';
import { RxSlider as SliderIcon } from 'react-icons/rx';
import { RiFontFamily as FontIcon } from 'react-icons/ri';
import { PiSun as ColorIcon } from 'react-icons/pi';
import { MdOutlineHeadphones as TTSIcon } from 'react-icons/md';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import Button from '@/components/Button';
interface NavigationBarProps {
bookKey: string;
actionTab: string;
navPadding: string;
onSetActionTab: (tab: string) => void;
}
export const NavigationBar: React.FC<NavigationBarProps> = ({
bookKey,
actionTab,
navPadding: mobileNavPadding,
onSetActionTab,
}) => {
const _ = useTranslation();
const { getViewState } = useReaderStore();
const viewState = getViewState(bookKey);
const tocIconSize = useResponsiveSize(23);
const fontIconSize = useResponsiveSize(18);
return (
<div
className={clsx('bg-base-200 z-30 mt-auto flex w-full justify-between px-8 py-4 sm:hidden')}
style={{ paddingBottom: mobileNavPadding }}
>
<Button
label={_('Table of Contents')}
icon={<TOCIcon size={tocIconSize} />}
onClick={() => onSetActionTab('toc')}
/>
<Button
label={_('Color')}
icon={<ColorIcon className={clsx(actionTab === 'color' && 'text-blue-500')} />}
onClick={() => onSetActionTab('color')}
/>
<Button
label={_('Reading Progress')}
icon={<SliderIcon className={clsx(actionTab === 'progress' && 'text-blue-500')} />}
onClick={() => onSetActionTab('progress')}
/>
<Button
label={_('Font & Layout')}
icon={
<FontIcon size={fontIconSize} className={clsx(actionTab === 'font' && 'text-blue-500')} />
}
onClick={() => onSetActionTab('font')}
/>
<Button
label={_('Speak')}
icon={<TTSIcon className={viewState?.ttsEnabled ? 'text-blue-500' : ''} />}
onClick={() => onSetActionTab('tts')}
/>
</div>
);
};
@@ -0,0 +1,138 @@
import clsx from 'clsx';
import React, { useCallback, useEffect } from 'react';
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
import { RiArrowGoBackLine, RiArrowGoForwardLine } from 'react-icons/ri';
import { RiArrowLeftDoubleLine, RiArrowRightDoubleLine } from 'react-icons/ri';
import { getNavigationIcon, getNavigationLabel, getNavigationHandler } from './utils';
import { useTranslation } from '@/hooks/useTranslation';
import { ViewSettings } from '@/types/book';
import { NavigationHandlers } from './types';
import Button from '@/components/Button';
import Slider from '@/components/Slider';
interface NavigationPanelProps {
actionTab: string;
progressFraction: number;
progressValid: boolean;
navigationHandlers: NavigationHandlers;
viewSettings?: ViewSettings;
bottomOffset: string;
sliderHeight: number;
}
export const NavigationPanel: React.FC<NavigationPanelProps> = ({
actionTab,
progressFraction,
progressValid,
navigationHandlers,
viewSettings,
bottomOffset: mobileBottomOffset,
sliderHeight,
}) => {
const _ = useTranslation();
const [progressValue, setProgressValue] = React.useState(
progressValid ? progressFraction * 100 : 0,
);
useEffect(() => {
if (progressValid) {
setProgressValue(progressFraction * 100);
}
}, [progressValid, progressFraction]);
const handleProgressChange = useCallback(
(value: number) => {
setProgressValue(value);
navigationHandlers.onProgressChange(value);
},
[navigationHandlers],
);
const classes = clsx(
'footerbar-progress-mobile bg-base-200 absolute flex w-full flex-col items-center gap-y-8 px-4 transition-all sm:hidden',
actionTab === 'progress'
? 'pointer-events-auto translate-y-0 pb-4 pt-8 ease-out'
: 'pointer-events-none invisible translate-y-full overflow-hidden pb-0 pt-0 ease-in',
);
return (
<div className={classes} style={{ bottom: mobileBottomOffset }}>
<div className='flex w-full items-center justify-between gap-x-6'>
<Slider
label={_('Reading Progress')}
heightPx={sliderHeight}
bubbleLabel={`${Math.round(progressValue)}%`}
initialValue={progressValue}
onChange={handleProgressChange}
/>
</div>
<div className='flex w-full items-center justify-between gap-x-6'>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowLeftDoubleLine />,
<RiArrowRightDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevSection,
navigationHandlers.onNextSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Section'), _('Next Section'))}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowLeftSLine />, <RiArrowRightSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onPrevPage,
navigationHandlers.onNextPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Previous Page'), _('Next Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowGoBackLine />,
<RiArrowGoForwardLine />,
)}
onClick={navigationHandlers.onGoBack}
label={_('Go Back')}
disabled={!navigationHandlers.onGoBack}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowGoForwardLine />,
<RiArrowGoBackLine />,
)}
onClick={navigationHandlers.onGoForward}
label={_('Go Forward')}
disabled={!navigationHandlers.onGoForward}
/>
<Button
icon={getNavigationIcon(viewSettings?.rtl, <RiArrowRightSLine />, <RiArrowLeftSLine />)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextPage,
navigationHandlers.onPrevPage,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Page'), _('Previous Page'))}
/>
<Button
icon={getNavigationIcon(
viewSettings?.rtl,
<RiArrowRightDoubleLine />,
<RiArrowLeftDoubleLine />,
)}
onClick={getNavigationHandler(
viewSettings?.rtl,
navigationHandlers.onNextSection,
navigationHandlers.onPrevSection,
)}
label={getNavigationLabel(viewSettings?.rtl, _('Next Section'), _('Previous Section'))}
/>
</div>
</div>
);
};
@@ -0,0 +1,32 @@
import { PageInfo } from '@/types/book';
import { Insets } from '@/types/misc';
export interface FooterBarProps {
bookKey: string;
bookFormat: string;
section?: PageInfo;
pageinfo?: PageInfo;
isHoveredAnim: boolean;
gridInsets: Insets;
}
export interface NavigationHandlers {
onPrevPage: () => void;
onNextPage: () => void;
onPrevSection: () => void;
onNextSection: () => void;
onGoBack: () => void;
onGoForward: () => void;
onProgressChange: (value: number) => void;
}
export interface FooterBarChildProps {
bookKey: string;
navigationHandlers: NavigationHandlers;
progressFraction: number;
progressValid: boolean;
gridInsets: Insets;
actionTab: string;
onSetActionTab: (tab: string) => void;
onSpeakText: () => void;
}
@@ -0,0 +1,19 @@
import React from 'react';
export const getNavigationIcon = (
rtl: boolean | undefined,
prevIcon: React.ReactNode,
nextIcon: React.ReactNode,
) => (rtl ? nextIcon : prevIcon);
export const getNavigationLabel = (
rtl: boolean | undefined,
prevLabel: string,
nextLabel: string,
) => (rtl ? nextLabel : prevLabel);
export const getNavigationHandler = (
rtl: boolean | undefined,
prevHandler: () => void,
nextHandler: () => void,
) => (rtl ? nextHandler : prevHandler);
@@ -230,7 +230,9 @@ const Notebook: React.FC = ({}) => {
}
`}</style>
<div
className='drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2'
className={clsx(
'drag-bar absolute -left-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-2',
)}
role='slider'
tabIndex={0}
aria-label={_('Resize Notebook')}
@@ -11,7 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { isCJKStr } from '@/utils/lang';
import { createRejecttFilter } from '@/utils/node';
import { createRejectFilter } from '@/utils/node';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -127,7 +127,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
...searchConfig,
index,
query: term,
acceptNode: createRejecttFilter({
acceptNode: createRejectFilter({
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
}),
});
@@ -242,6 +242,20 @@ const SideBar: React.FC<{
}
}
`}</style>
<div
className={clsx(
'drag-bar absolute -right-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-1',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='horizontal'
aria-valuenow={parseFloat(sideBarWidth)}
onMouseDown={handleHorizontalDragStart}
onTouchStart={handleHorizontalDragStart}
onKeyDown={handleDragKeyDown}
></div>
<div className='flex-shrink-0'>
{isMobile && (
<div
@@ -291,20 +305,6 @@ const SideBar: React.FC<{
) : (
<SidebarContent bookDoc={bookDoc} sideBarBookKey={sideBarBookKey!} />
)}
<div
className={clsx(
'drag-bar absolute -right-2 top-0 h-full w-0.5 cursor-col-resize bg-transparent p-1',
isMobile && 'hidden',
)}
role='slider'
tabIndex={0}
aria-label={_('Resize Sidebar')}
aria-orientation='horizontal'
aria-valuenow={parseFloat(sideBarWidth)}
onMouseDown={handleHorizontalDragStart}
onTouchStart={handleHorizontalDragStart}
onKeyDown={handleDragKeyDown}
></div>
</div>
</>
) : null;
@@ -68,8 +68,8 @@ const TOCItemView = React.memo<{
className={clsx(
'flex w-full cursor-pointer items-center rounded-md py-4 sm:py-2',
isActive
? 'sm:bg-base-300/85 sm:hover:bg-base-300 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/85',
? 'sm:bg-base-300/65 sm:hover:bg-base-300/75 sm:text-base-content text-blue-500'
: 'sm:hover:bg-base-300/75',
)}
style={{
height: itemSize ? `${itemSize}px` : 'auto',
@@ -137,6 +137,7 @@ export const StaticListRow: React.FC<ListRowProps> = ({
'border-base-300 w-full border-b sm:border-none',
'pe-4 ps-2 pt-[1px] sm:pe-2',
)}
title={flatItem.item.label || ''}
>
<TOCItemView
bookKey={bookKey}
@@ -166,7 +167,7 @@ export const VirtualListRow: React.FC<
const flatItem = flatItems[index];
return (
<div style={style}>
<div style={style} title={flatItem.item.label || ''}>
<StaticListRow
bookKey={bookKey}
flatItem={flatItem}
@@ -51,6 +51,7 @@ const TOCView: React.FC<{
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [containerHeight, setContainerHeight] = useState(400);
const hasToggleExpandRef = useRef(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const listOuterRef = useRef<HTMLDivElement | null>(null);
const vitualListRef = useRef<VirtualList | null>(null);
@@ -133,6 +134,7 @@ const TOCView: React.FC<{
const handleToggleExpand = useCallback((item: TOCItem) => {
const itemId = getItemIdentifier(item);
hasToggleExpandRef.current = true;
setExpandedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
@@ -175,8 +177,11 @@ const TOCView: React.FC<{
const hrefMd5 = activeHref ? getContentMd5(activeHref) : '';
const activeItem = staticListRef.current?.querySelector(`[data-href="${hrefMd5}"]`);
if (activeItem) {
const rect = activeItem.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight;
const container = staticListRef.current.parentElement!;
const containerRect = container.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
const isVisible =
itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
if (!isVisible) {
(activeItem as HTMLElement).scrollIntoView({ behavior: 'instant', block: 'center' });
}
@@ -213,11 +218,15 @@ const TOCView: React.FC<{
}, [toc, progress, viewState, sideBarBookKey, isSideBarVisible, bookKey, expandParents]);
useEffect(() => {
if (hasToggleExpandRef.current) {
hasToggleExpandRef.current = false;
return;
}
if (flatItems.length > 0) {
setTimeout(scrollToActiveItem, appService?.isAndroidApp ? 300 : 100);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
}, [progress, scrollToActiveItem]);
return sections && sections.length > 256 ? (
<div
@@ -19,14 +19,14 @@ const TabNavigation: React.FC<{
return (
<div
className={clsx(
'bottom-tab border-base-300/50 bg-base-200 relative flex w-full border-t',
'bottom-tab border-base-300/50 bg-base-200/20 relative flex w-full border-t',
appService?.hasRoundedWindow && 'rounded-window-bottom-left',
)}
dir='ltr'
>
<div
className={clsx(
'bg-base-300 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'bg-base-300/85 absolute bottom-1.5 start-1 z-10 h-[calc(100%-12px)] w-[calc(33.3%-8px)] rounded-lg',
'transform transition-transform duration-300',
activeTab === 'toc' && 'translate-x-0',
activeTab === 'annotations' && 'translate-x-[calc(100%+8px)]',
@@ -54,6 +54,9 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const [timeoutTimestamp, setTimeoutTimestamp] = useState(0);
const [timeoutFunc, setTimeoutFunc] = useState<ReturnType<typeof setTimeout> | null>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [showIndicatorWithinTimeout, setShowIndicatorWithinTimeout] = useState(true);
const popupPadding = useResponsiveSize(POPUP_PADDING);
const maxWidth = window.innerWidth - 2 * popupPadding;
const popupWidth = Math.min(maxWidth, useResponsiveSize(POPUP_WIDTH));
@@ -208,6 +211,30 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
viewSettings.ttsLocation = cfi || '';
setViewSettings(bookKey, viewSettings);
}
if (!view.renderer.scrolled) {
view.renderer.scrollToAnchor(range);
} else {
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = view.renderer;
const positionStart = rect[sideProp === 'height' ? 'y' : 'x'] + viewSettings.marginTopPx;
const positionEnd = rect[sideProp === 'height' ? 'height' : 'width'] + positionStart;
const offsetStart = view.book.dir === 'rtl' ? viewSize - positionStart : positionStart;
const offsetEnd = view.book.dir === 'rtl' ? viewSize - positionEnd : positionEnd;
const showHeader = viewSettings.showHeader;
const showFooter = viewSettings.showFooter;
const showBarsOnScroll = viewSettings.showBarsOnScroll;
const headerScrollOverlap = showHeader && showBarsOnScroll ? 44 : 0;
const footerScrollOverlap = showFooter && showBarsOnScroll ? 44 : 0;
const scrollingOverlap = viewSettings.scrollingOverlap;
const endInNextView = offsetEnd > start + size - footerScrollOverlap - scrollingOverlap;
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
if (endInNextView || startInPrevView) {
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
view.renderer.scrollToAnchor(scrollTo / viewSize);
}
}
};
ttsController.addEventListener('tts-speak-mark', handleSpeakMark);
@@ -324,6 +351,15 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
await ttsController.start();
}
}
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
await mediaSession.updatePlaybackState({ playing: !isPlaying });
} else {
mediaSession.playbackState = isPlaying ? 'paused' : 'playing';
}
}
}, [isPlaying, isPaused]);
const handleBackward = useCallback(async (byMark = false) => {
@@ -538,10 +574,38 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showPanel]);
useEffect(() => {
if (hoveredBookKey) {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
const showTimeout = setTimeout(() => {
setShowIndicatorWithinTimeout(true);
}, 100);
hoverTimeoutRef.current = showTimeout;
} else {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
const hideTimeout = setTimeout(() => {
setShowIndicatorWithinTimeout(false);
}, 5000);
hoverTimeoutRef.current = hideTimeout;
}
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, [hoveredBookKey]);
return (
<>
{showPanel && <Overlay onDismiss={handleDismissPopup} />}
{showIndicator && (
{(showPanel || (showIndicator && showIndicatorWithinTimeout)) && (
<div
ref={iconRef}
className={clsx(
@@ -5,7 +5,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getStyles } from '@/utils/style';
import { tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { tauriHandleClose, tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
import { eventDispatcher } from '@/utils/event';
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
import { viewPagination } from './usePagination';
@@ -95,6 +95,12 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
}
};
const closeWindow = async () => {
if (isTauriAppPlatform()) {
await tauriHandleClose();
}
};
const quitApp = async () => {
// on web platform use browser's default shortcut to close the tab
if (isTauriAppPlatform()) {
@@ -145,17 +151,24 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) =
eventDispatcher.dispatch(viewState?.ttsEnabled ? 'tts-stop' : 'tts-speak', { bookKey });
};
const toggleBookmark = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('toggle-bookmark', { bookKey: sideBarBookKey });
};
useShortcuts(
{
onSwitchSideBar: switchSideBar,
onToggleSideBar: toggleSideBar,
onToggleNotebook: toggleNotebook,
onToggleScrollMode: toggleScrollMode,
onToggleBookmark: toggleBookmark,
onOpenFontLayoutSettings: () => setFontLayoutSettingsDialogOpen(true),
onToggleSearchBar: showSearchBar,
onToggleFullscreen: toggleFullscreen,
onToggleTTS: toggleTTS,
onReloadPage: reloadPage,
onCloseWindow: closeWindow,
onQuitApp: quitApp,
onGoLeft: goLeft,
onGoRight: goRight,
@@ -112,7 +112,7 @@ export const useTouchEvent = (
// also check for deltaX to prevent swipe page turn from triggering the toggle
if (
deltaY < -10 &&
Math.abs(deltaY) > Math.abs(deltaX) &&
Math.abs(deltaY) > Math.abs(deltaX) * 2 &&
Math.abs(deltaX) < windowWidth * 0.3
) {
// swipe up to toggle the header bar and the footer bar, only for horizontal page mode
@@ -115,7 +115,11 @@ export const usePagination = (
}
}
}
} else if (msg.data.type === 'iframe-wheel' && !viewSettings.scrolled) {
} else if (
msg.data.type === 'iframe-wheel' &&
!viewSettings.scrolled &&
(!bookData.isFixedLayout || viewSettings.zoomLevel <= 100)
) {
// The wheel event is handled by the iframe itself in scrolled mode.
const { deltaY } = msg.data;
if (deltaY > 0) {
@@ -143,8 +147,8 @@ export const usePagination = (
}
} else if (
msg.type === 'touch-swipe' &&
bookData.bookDoc?.rendition?.layout === 'pre-paginated' &&
viewSettings?.zoomLevel === 100
bookData.isFixedLayout &&
viewSettings!.zoomLevel <= 100
) {
const { deltaX, deltaY } = msg.detail;
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30) {
@@ -142,7 +142,11 @@ export const useProgressSync = (bookKey: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
);
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
if (syncedConfig.updatedAt >= config.updatedAt) {
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
} else {
setConfig(bookKey, { ...filteredSyncedConfig, ...config });
}
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
if (view) {
+1 -1
View File
@@ -225,7 +225,7 @@ const Dialog: React.FC<DialogProps> = ({
>
<div className='bg-base-content/50 h-1 w-10 rounded-full'></div>
</div>
<div className='dialog-header bg-base-100 sticky top-1 z-10 flex items-center justify-between px-2 sm:px-4'>
<div className='dialog-header sticky top-1 z-10 flex items-center justify-between px-2 sm:px-4'>
{header ? (
header
) : (
+19 -4
View File
@@ -9,13 +9,19 @@ import { CSPostHogProvider } from '@/context/PHContext';
import { SyncProvider } from '@/context/SyncContext';
import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets';
import { useDefaultIconSize } from '@/hooks/useResponsiveSize';
import { useBackgroundTexture } from '@/hooks/useBackgroundTexture';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getLocale } from '@/utils/misc';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
const { applyUILanguage } = useSettingsStore();
const { setScreenBrightness } = useDeviceControlStore();
const { applyBackgroundTexture } = useBackgroundTexture();
const { applyEinkMode } = useEinkMode();
const iconSize = useDefaultIconSize();
useSafeAreaInsets(); // Initialize safe area insets
@@ -37,10 +43,19 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
if (appService) {
initSystemThemeListener(appService);
appService.loadSettings().then((settings) => {
applyUILanguage(settings.globalViewSettings?.uiLanguage);
const globalViewSettings = settings.globalViewSettings;
applyUILanguage(globalViewSettings.uiLanguage);
const brightness = settings.screenBrightness;
if (appService.hasScreenBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
applyBackgroundTexture(envConfig, globalViewSettings);
if (appService.isAndroidApp) {
applyEinkMode(globalViewSettings.isEink);
}
});
}
}, [appService, applyUILanguage]);
}, [envConfig, appService, applyUILanguage, setScreenBrightness, applyBackgroundTexture]);
// Make sure appService is available in all children components
if (!appService) return;
+10 -5
View File
@@ -9,6 +9,8 @@ interface SliderProps {
heightPx?: number;
minLabel?: string;
maxLabel?: string;
minIcon?: React.ReactNode;
maxIcon?: React.ReactNode;
bubbleElement?: React.ReactNode;
bubbleLabel?: string;
className?: string;
@@ -27,6 +29,8 @@ const Slider: React.FC<SliderProps> = ({
heightPx = 40,
minLabel = '',
maxLabel = '',
minIcon,
maxIcon,
bubbleElement,
bubbleLabel = '',
className = '',
@@ -63,6 +67,7 @@ const Slider: React.FC<SliderProps> = ({
}, [initialValue]);
const percentage = ((value - min) / (max - min)) * 100;
const visualPercentage = (percentage / 100) * 95;
return (
<div
@@ -79,22 +84,22 @@ const Slider: React.FC<SliderProps> = ({
className='bg-base-300 absolute h-full rounded-full'
style={{
width:
percentage > 0
? `max(calc(${percentage}% + ${heightPx / 2}px), ${heightPx}px)`
visualPercentage > 0
? `max(calc(${visualPercentage}% + ${heightPx / 2}px), ${heightPx}px)`
: '0px',
[isRtl ? 'right' : 'left']: 0,
}}
></div>
{/* Min/Max labels */}
<div className='absolute inset-0 flex items-center justify-between px-4 text-sm'>
<span className={`ml-2 ${minClassName}`}>{minLabel}</span>
<span className={`mr-2 ${maxClassName}`}>{maxLabel}</span>
{minIcon ? minIcon : <span className={`ml-2 ${minClassName}`}>{minLabel}</span>}
{maxIcon ? maxIcon : <span className={`mr-2 ${maxClassName}`}>{maxLabel}</span>}
</div>
{/* Thumb bubble */}
<div
className='pointer-events-none absolute top-0 z-10'
style={{
[isRtl ? 'right' : 'left']: `max(${heightPx / 2}px, calc(${percentage}%))`,
[isRtl ? 'right' : 'left']: `max(${heightPx / 2}px, calc(${visualPercentage}%))`,
transform: isRtl ? 'translateX(calc(50%))' : 'translateX(calc(-50%))',
height: '100%',
}}
@@ -28,7 +28,7 @@ const WindowButton: React.FC<WindowButtonProps> = ({ onClick, label, id, childre
<button
id={id}
onClick={onClick}
className='window-button text-base-content/85 hover:text-base-content'
className='window-button bg-base-200/35 hover:bg-base-200 text-base-content/85 hover:text-base-content'
aria-label={label}
>
{children}
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
import { MdOutlineLightMode, MdOutlineDarkMode, MdClose, MdAdd } from 'react-icons/md';
import { MdRadioButtonUnchecked, MdRadioButtonChecked } from 'react-icons/md';
import { CgColorPicker } from 'react-icons/cg';
import { TbSunMoon } from 'react-icons/tb';
@@ -19,9 +19,12 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { saveViewSettings } from '@/helpers/viewSettings';
import { CODE_LANGUAGES, CodeLanguage, manageSyntaxHighlighting } from '@/utils/highlightjs';
import { SettingsPanelPanelProp } from './SettingsDialog';
import { useFileSelector } from '@/hooks/useFileSelector';
import { PREDEFINED_TEXTURES } from '@/styles/textures';
import Select from '@/components/Select';
import ThemeEditor from './ThemeEditor';
@@ -29,7 +32,7 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const _ = useTranslation();
const { themeMode, themeColor, isDarkMode, setThemeMode, setThemeColor, saveCustomTheme } =
useThemeStore();
const { envConfig } = useEnv();
const { envConfig, appService } = useEnv();
const { settings, setSettings } = useSettingsStore();
const { getView, getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -46,8 +49,23 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [codeHighlighting, setcodeHighlighting] = useState(viewSettings.codeHighlighting);
const [codeLanguage, setCodeLanguage] = useState(viewSettings.codeLanguage);
const [selectedTextureId, setSelectedTextureId] = useState(viewSettings.backgroundTextureId);
const [backgroundOpacity, setBackgroundOpacity] = useState(viewSettings.backgroundOpacity);
const [backgroundSize, setBackgroundSize] = useState(viewSettings.backgroundSize);
const {
textures: customTextures,
addTexture,
loadTexture,
applyTexture,
removeTexture,
loadCustomTextures,
saveCustomTextures,
} = useCustomTextureStore();
const resetToDefaults = useResetViewSettings();
const { selectFiles } = useFileSelector(appService, _);
const handleReset = () => {
resetToDefaults({
overrideColor: setOverrideColor,
@@ -57,6 +75,9 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
});
setThemeColor('default');
setThemeMode('auto');
setSelectedTextureId('none');
setBackgroundOpacity(0.4);
setBackgroundSize('2048px');
};
useEffect(() => {
@@ -64,6 +85,10 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
loadCustomTextures(envConfig);
}, [loadCustomTextures, envConfig]);
useEffect(() => {
if (invertImgColorInDark === viewSettings.invertImgColorInDark) return;
saveViewSettings(envConfig, bookKey, 'invertImgColorInDark', invertImgColorInDark);
@@ -94,6 +119,33 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeHighlighting, codeLanguage]);
useEffect(() => {
if (selectedTextureId === viewSettings.backgroundTextureId) return;
saveViewSettings(envConfig, bookKey, 'backgroundTextureId', selectedTextureId);
applyBackgroundTexture();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTextureId]);
useEffect(() => {
if (backgroundOpacity === viewSettings.backgroundOpacity) return;
saveViewSettings(envConfig, bookKey, 'backgroundOpacity', backgroundOpacity);
applyBackgroundTexture();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backgroundOpacity]);
useEffect(() => {
if (backgroundSize === viewSettings.backgroundSize) return;
saveViewSettings(envConfig, bookKey, 'backgroundSize', backgroundSize);
applyBackgroundTexture();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backgroundSize]);
const applyBackgroundTexture = () => {
applyTexture(envConfig, selectedTextureId);
document.documentElement.style.setProperty('--bg-texture-opacity', `${backgroundOpacity}`);
document.documentElement.style.setProperty('--bg-texture-size', backgroundSize);
};
useEffect(() => {
const customThemes = settings.globalReadSettings.customThemes ?? [];
setCustomThemes(
@@ -134,6 +186,38 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
}
};
const handleImportImage = () => {
selectFiles({ type: 'images', multiple: true }).then(async (result) => {
if (result.error || result.files.length === 0) return;
for (const selectedFile of result.files) {
const textureInfo = await appService?.importImage(selectedFile.path || selectedFile.file);
if (!textureInfo) continue;
const customTexture = addTexture(textureInfo.path);
console.log('Added custom texture:', customTexture);
if (customTexture && !customTexture.error) {
await loadTexture(envConfig, customTexture.id);
}
}
saveCustomTextures(envConfig);
});
};
const handleDeleteCustomTexture = (textureId: string) => {
removeTexture(textureId);
const updatedTextures = customTextures.filter((t) => t.id !== textureId);
settings.customTextures = updatedTextures;
setSettings(settings);
if (selectedTextureId === textureId) {
setSelectedTextureId('none');
}
saveCustomTextures(envConfig);
};
const allTextures = [...PREDEFINED_TEXTURES, ...customTextures.filter((t) => !t.deletedAt)];
return (
<div className='my-4 w-full space-y-6'>
{showCustomThemeEditor ? (
@@ -249,6 +333,106 @@ const ColorPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
</div>
</div>
<div>
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
<div className='mb-4 grid grid-cols-2 gap-4'>
{allTextures.map((texture) => (
<button
key={texture.id}
onClick={() => setSelectedTextureId(texture.id)}
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
selectedTextureId === texture.id
? 'ring-2 ring-indigo-500 ring-offset-2'
: 'border-base-300'
}`}
style={{
backgroundImage: texture.loaded
? `url("${texture.blobUrl || texture.url}")`
: 'none',
backgroundSize: 'cover',
backgroundPosition: 'top',
minHeight: '80px',
}}
>
{selectedTextureId === texture.id && (
<MdRadioButtonChecked
size={iconSize24}
className='absolute right-2 top-2 rounded-full bg-white text-indigo-500'
/>
)}
{!PREDEFINED_TEXTURES.find((t) => t.id === texture.id) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteCustomTexture(texture.id);
}}
className='absolute left-2 top-2 rounded-full bg-red-500 p-1 text-white transition-colors hover:bg-red-600'
title={_('Delete')}
>
<MdClose size={16} />
</button>
)}
</button>
))}
{/* Custom Image Upload */}
<div
className={`border-base-300 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 shadow-md transition-all`}
style={{ minHeight: '80px' }}
>
<button
className='card-body flex cursor-pointer items-center justify-center p-2 text-center'
onClick={handleImportImage}
>
<div className='flex items-center gap-2'>
<div className='flex items-center justify-center'>
<MdAdd className='text-primary/85 group-hover:text-primary h-6 w-6' />
</div>
<div className='text-primary/85 group-hover:text-primary line-clamp-1 font-medium'>
{_('Import Image')}
</div>
</div>
</button>
</div>
</div>
{/* Background Image Settings */}
{selectedTextureId !== 'none' && (
<div className='card border-base-200 bg-base-100 space-y-4 border p-4 shadow'>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Opacity')}</span>
<div className='flex items-center gap-2'>
<input
type='range'
min='0'
max='1'
step='0.05'
value={backgroundOpacity}
onChange={(e) => setBackgroundOpacity(parseFloat(e.target.value))}
className='range range-sm w-32'
/>
<span className='w-12 text-right text-sm'>
{Math.round(backgroundOpacity * 100)}%
</span>
</div>
</div>
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{_('Size')}</span>
<Select
value={backgroundSize}
onChange={(e) => setBackgroundSize(e.target.value)}
options={[
{ value: 'auto', label: _('Auto') },
{ value: 'cover', label: _('Cover') },
{ value: 'contain', label: _('Contain') },
]}
/>
</div>
</div>
)}
</div>
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Code Highlighting')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
@@ -6,6 +6,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResetViewSettings } from '@/hooks/useResetSettings';
import { useEinkMode } from '@/hooks/useEinkMode';
import { getStyles } from '@/utils/style';
import { saveAndReload } from '@/utils/reload';
import { getMaxInlineSize } from '@/utils/config';
@@ -19,6 +20,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const { getView, getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { settings } = useSettingsStore();
const { applyEinkMode } = useEinkMode();
const { acquireVolumeKeyInterception, releaseVolumeKeyInterception } = useDeviceControlStore();
const bookData = getBookData(bookKey);
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
@@ -31,6 +33,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
const [swapClickArea, setSwapClickArea] = useState(viewSettings.swapClickArea);
const [isDisableDoubleClick, setIsDisableDoubleClick] = useState(viewSettings.disableDoubleClick);
const [animated, setAnimated] = useState(viewSettings.animated);
const [isEink, setIsEink] = useState(viewSettings.isEink);
const [allowScript, setAllowScript] = useState(viewSettings.allowScript);
const resetToDefaults = useResetViewSettings();
@@ -44,6 +47,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
disableClick: setIsDisableClick,
swapClickArea: setSwapClickArea,
animated: setAnimated,
isEink: setIsEink,
allowScript: setAllowScript,
});
};
@@ -113,6 +117,17 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animated]);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'isEink', isEink, false, false);
if (isEink) {
getView(bookKey)?.renderer.setAttribute('eink', '');
} else {
getView(bookKey)?.renderer.removeAttribute('eink');
}
applyEinkMode(isEink);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isEink]);
useEffect(() => {
if (viewSettings.allowScript === allowScript) return;
saveViewSettings(envConfig, bookKey, 'allowScript', allowScript, true, false);
@@ -221,6 +236,25 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
</div>
</div>
{appService?.isAndroidApp && (
<div className='w-full'>
<h2 className='mb-2 font-medium'>{_('Device')}</h2>
<div className='card border-base-200 bg-base-100 border shadow'>
<div className='divide-base-200 divide-y'>
<div className='config-item'>
<span className=''>{_('E-Ink Mode')}</span>
<input
type='checkbox'
className='toggle'
checked={isEink}
onChange={() => setIsEink(!isEink)}
/>
</div>
</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'>
@@ -462,7 +462,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={setParagraphMargin}
min={0}
max={4}
step={0.2}
step={0.1}
/>
<NumberInput
label={_('Line Spacing')}
+5 -6
View File
@@ -19,12 +19,11 @@ const posthogKey =
atob(process.env['NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64']!);
if (typeof window !== 'undefined' && process.env['NODE_ENV'] === 'production' && posthogKey) {
posthog.init(posthogKey, {
api_host: posthogUrl,
person_profiles: 'always',
});
if (shouldDisablePostHog()) {
posthog.opt_out_capturing();
if (!shouldDisablePostHog()) {
posthog.init(posthogKey, {
api_host: posthogUrl,
person_profiles: 'always',
});
}
}
export const CSPostHogProvider = ({ children }: { children: ReactNode }) => {
+8 -5
View File
@@ -1,23 +1,26 @@
const DEFAULT_SHORTCUTS = {
onSwitchSideBar: ['ctrl+Tab', 'opt+Tab', 'alt+Tab'],
onToggleSideBar: ['s'],
onToggleSideBar: ['s', 'F9'],
onToggleNotebook: ['n'],
onToggleSearchBar: ['ctrl+f', 'cmd+f'],
onToggleScrollMode: ['shift+j'],
onToggleSelectMode: ['shift+s'],
onToggleBookmark: ['ctrl+d', 'cmd+d'],
onToggleTTS: ['t'],
onOpenFontLayoutSettings: ['shift+f'],
onOpenFontLayoutSettings: ['shift+f', 'ctrl+,', 'cmd+,'],
onOpenBooks: ['ctrl+o', 'cmd+o'],
onReloadPage: ['shift+r'],
onToggleFullscreen: ['F11'],
onCloseWindow: ['ctrl+w', 'cmd+w'],
onQuitApp: ['ctrl+q', 'cmd+q'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h'],
onGoLeft: ['ArrowLeft', 'PageUp', 'h', 'shift+ '],
onGoRight: ['ArrowRight', 'PageDown', 'l', ' '],
onGoNext: ['ArrowDown', 'j'],
onGoPrev: ['ArrowUp', 'k'],
onGoHalfPageDown: ['shift+ArrowDown', 'd'],
onGoHalfPageUp: ['shift+ArrowUp', 'u'],
onGoBack: ['shift+ArrowLeft', 'shift+h'],
onGoForward: ['shift+ArrowRight', 'shift+l'],
onGoBack: ['shift+ArrowLeft', 'shift+h', 'alt+ArrowLeft'],
onGoForward: ['shift+ArrowRight', 'shift+l', 'alt+ArrowRight'],
onZoomIn: ['ctrl+=', 'cmd+=', 'shift+='],
onZoomOut: ['ctrl+-', 'cmd+-', 'shift+-'],
onResetZoom: ['ctrl+0', 'cmd+0'],
@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { useCustomTextureStore } from '@/store/customTextureStore';
import { useSettingsStore } from '@/store/settingsStore';
import { EnvConfigType } from '@/services/environment';
import { ViewSettings } from '@/types/book';
export const useBackgroundTexture = () => {
const applyBackgroundTexture = useCallback(
(envConfig: EnvConfigType, viewSettings: ViewSettings) => {
const textureId = viewSettings.backgroundTextureId;
const textureOpacity = viewSettings.backgroundOpacity;
const textureSize = viewSettings.backgroundSize;
if (!textureId || textureId === 'none') return;
document.documentElement.style.setProperty('--bg-texture-opacity', `${textureOpacity}`);
document.documentElement.style.setProperty('--bg-texture-size', textureSize);
const settings = useSettingsStore.getState().settings;
const customTexture = settings.customTextures?.find((t) => t.id === textureId);
if (customTexture) {
useCustomTextureStore.getState().addTexture(customTexture.path);
}
useCustomTextureStore.getState().applyTexture(envConfig, textureId);
},
[],
);
return { applyBackgroundTexture };
};

Some files were not shown because too many files have changed in this diff Show More