Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05e9549410 | |||
| 35fbce3506 | |||
| c1d6825793 | |||
| ae6c6970b4 | |||
| f8fef57cf3 | |||
| 4dc191735d | |||
| 4f11f437e1 | |||
| 5773e31990 | |||
| 9dda5d4c88 | |||
| 7f5b6959e1 | |||
| 126981d085 | |||
| 854a578929 | |||
| 6dd0daa597 | |||
| fd24a5e9ac | |||
| 948b35f244 | |||
| 96cc182a8c | |||
| e2291ed5b1 | |||
| 60ddb17547 | |||
| 9a991a31ba | |||
| 6de10c6c3b | |||
| c7d49c1504 | |||
| c721bd4d97 | |||
| 5b0a1968d8 | |||
| ad9064fa46 | |||
| f1c92e5702 | |||
| 92c1441922 | |||
| 3820950c7c | |||
| 08e558bd41 | |||
| 00af4a3d60 | |||
| ada427b134 | |||
| f696b9a573 | |||
| 195beeacac | |||
| 5b23ed35b6 | |||
| 42c8702704 | |||
| ad81376da8 | |||
| 562fea3a36 | |||
| b69d9ed69f | |||
| ad6a21a68f | |||
| 173aa0fcd1 | |||
| 788e08a75a | |||
| ec397a2daf | |||
| 2a5758fbc4 | |||
| 562ccd4b9e | |||
| 4d577774f3 | |||
| 4c10c16491 | |||
| 7695f31a5a | |||
| 06b27f8d39 |
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
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}} pagina’s 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 模式"
|
||||
}
|
||||
|
||||
@@ -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 didn’t 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)
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||