diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index b3279f50..d2cc1e46 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -597,7 +597,6 @@ "Start Migration": "بدء الترحيل", "Advanced Settings": "إعدادات متقدمة", "File count: {{size}}": "عدد الملفات: {{size}}", - "Background Read Aloud": "قراءة بصوت عالٍ في الخلفية", "Screen Brightness": "سطوع الشاشة", "Background Image": "صورة الخلفية", "Import Image": "استيراد صورة", diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index e7f65146..7b4843ec 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -581,7 +581,6 @@ "Start Migration": "মাইগ্রেশন শুরু করুন", "Advanced Settings": "উন্নত সেটিংস", "File count: {{size}}": "ফাইলের সংখ্যা: {{size}}", - "Background Read Aloud": "পটভূমিতে উচ্চস্বরে পড়ুন", "Screen Brightness": "স্ক্রিন উজ্জ্বলতা", "Background Image": "ব্যাকগ্রাউন্ড ছবি", "Import Image": "ছবি আমদানি করুন", diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index c26f8408..b46e1d10 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -577,7 +577,6 @@ "Start Migration": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།", "Advanced Settings": "དབང་བསྐྱོད་སྒྲིག་སྟངས།", "File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}", - "Background Read Aloud": "གནས་སྟངས་ཀྱིས་ཀློག་བྱེད།", "Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར།", "Background Image": "གནས་སྟངས་ཀྱི་རི་མོ།", "Import Image": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།", diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 5026b357..12300149 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -581,7 +581,6 @@ "Start Migration": "Migration starten", "Advanced Settings": "Erweiterte Einstellungen", "File count: {{size}}": "Dateianzahl: {{size}}", - "Background Read Aloud": "Hintergrund-Vorlesen", "Screen Brightness": "Bildschirmhelligkeit", "Background Image": "Hintergrundbild", "Import Image": "Bild importieren", diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 7f31d793..69c26ee1 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -581,7 +581,6 @@ "Start Migration": "Έναρξη μετανάστευσης", "Advanced Settings": "Προηγμένες ρυθμίσεις", "File count: {{size}}": "Αριθμός αρχείων: {{size}}", - "Background Read Aloud": "Ανάγνωση στο παρασκήνιο", "Screen Brightness": "Φωτεινότητα οθόνης", "Background Image": "Εικόνα φόντου", "Import Image": "Εισαγωγή εικόνας", diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 3b9d2f22..424504f3 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -585,7 +585,6 @@ "Start Migration": "Iniciar Migración", "Advanced Settings": "Configuraciones Avanzadas", "File count: {{size}}": "Número de archivos: {{size}}", - "Background Read Aloud": "Lectura en segundo plano", "Screen Brightness": "Brillo de pantalla", "Background Image": "Imagen de fondo", "Import Image": "Importar imagen", diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 0e10c4e7..12ee8031 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -581,7 +581,6 @@ "Start Migration": "شروع انتقال", "Advanced Settings": "تنظیمات پیشرفته", "File count: {{size}}": "تعداد فایل‌ها: {{size}}", - "Background Read Aloud": "خواندن با صدای بلند در پس‌زمینه", "Screen Brightness": "روشنایی صفحه", "Background Image": "تصویر پس‌زمینه", "Import Image": "وارد کردن تصویر", diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 1ff02f87..969a10a8 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -585,7 +585,6 @@ "Start Migration": "Démarrer la Migration", "Advanced Settings": "Paramètres Avancés", "File count: {{size}}": "Nombre de fichiers : {{size}}", - "Background Read Aloud": "Lecture Vocale en Arrière-Plan", "Screen Brightness": "Luminosité de l'Écran", "Background Image": "Image de Fond", "Import Image": "Importer une Image", diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index ca36200d..8102b9c9 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -144,7 +144,6 @@ "Always on Top": "תמיד למעלה", "Always Show Status Bar": "תמיד הצג שורת סטטוס", "Keep Screen Awake": "השאר מסך פעיל", - "Background Read Aloud": "הקראה ברקע", "Reload Page": "טען דף מחדש", "Settings": "הגדרות", "Advanced Settings": "הגדרות מתקדמות", diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index f5280f89..004659a8 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -581,7 +581,6 @@ "Start Migration": "माइग्रेशन प्रारंभ करें", "Advanced Settings": "उन्नत सेटिंग्स", "File count: {{size}}": "फ़ाइलों की संख्या: {{size}}", - "Background Read Aloud": "पृष्ठभूमि में वाचन", "Screen Brightness": "स्क्रीन ब्राइटनेस", "Background Image": "पृष्ठभूमि छवि", "Import Image": "छवि आयात करें", diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index 6a0b828d..8459d676 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -158,7 +158,6 @@ "Always on Top": "Mindig felül", "Always Show Status Bar": "Állapotsor mindig látható", "Keep Screen Awake": "Képernyő ébren tartása", - "Background Read Aloud": "Felolvasás a háttérben", "Reload Page": "Oldal újratöltése", "Settings": "Beállítások", "Advanced Settings": "Speciális beállítások", diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index bf44ef39..8b5cab16 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -577,7 +577,6 @@ "Start Migration": "Mulai Migrasi", "Advanced Settings": "Pengaturan Lanjutan", "File count: {{size}}": "Jumlah file: {{size}}", - "Background Read Aloud": "Bacakan Latar Belakang", "Screen Brightness": "Kecerahan Layar", "Background Image": "Gambar Latar Belakang", "Import Image": "Impor Gambar", diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index b8042bb3..703ec1b3 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -585,7 +585,6 @@ "Start Migration": "Inizia Migrazione", "Advanced Settings": "Impostazioni Avanzate", "File count: {{size}}": "Conteggio file: {{size}}", - "Background Read Aloud": "Riproduzione in background", "Screen Brightness": "Luminosità dello schermo", "Background Image": "Immagine di sfondo", "Import Image": "Importa immagine", diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 8ce81df8..78ed994c 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -577,7 +577,6 @@ "Start Migration": "移行を開始", "Advanced Settings": "詳細設定", "File count: {{size}}": "ファイル数: {{size}}", - "Background Read Aloud": "バックグラウンド読み上げ", "Screen Brightness": "画面の明るさ", "Background Image": "背景画像", "Import Image": "画像をインポート", diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 2f3ff59e..e335d8f3 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -577,7 +577,6 @@ "Start Migration": "마이그레이션 시작", "Advanced Settings": "고급 설정", "File count: {{size}}": "파일 수: {{size}}", - "Background Read Aloud": "백그라운드 음성 읽기", "Screen Brightness": "화면 밝기", "Background Image": "배경 이미지", "Import Image": "이미지 가져오기", diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 22ed37ea..82cbe261 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -118,7 +118,6 @@ "Always on Top": "Sentiasa di Atas", "Always Show Status Bar": "Sentiasa Tunjuk Bar Status", "Keep Screen Awake": "Kekalkan Skrin Berjaga", - "Background Read Aloud": "Baca Dengan Kuat di Latar Belakang", "Reload Page": "Muat Semula Halaman", "Settings": "Tetapan", "Advanced Settings": "Tetapan Lanjutan", diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 718f2894..907734b5 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -581,7 +581,6 @@ "Start Migration": "Start Migratie", "Advanced Settings": "Geavanceerde Instellingen", "File count: {{size}}": "Bestandsaantal: {{size}}", - "Background Read Aloud": "Achtergrond Voorlezen", "Screen Brightness": "Schermhelderheid", "Background Image": "Achtergrondafbeelding", "Import Image": "Afbeelding importeren", diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index 22a84215..5b3dbdd5 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -589,7 +589,6 @@ "Start Migration": "Rozpocznij migrację", "Advanced Settings": "Ustawienia zaawansowane", "File count: {{size}}": "Liczba plików: {{size}}", - "Background Read Aloud": "Czytanie na głos w tle", "Screen Brightness": "Jasność ekranu", "Background Image": "Obraz tła", "Import Image": "Importuj obraz", diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 41dfbb6c..a4889713 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -164,7 +164,6 @@ "Fullscreen": "Tela cheia", "Always on Top": "Sempre no topo", "Always Show Status Bar": "Sempre exibir a barra de status", - "Background Read Aloud": "Leitura em segundo plano", "Settings": "Configurações", "Advanced Settings": "Configurações avançadas", "Refresh Metadata": "Atualizar metadados", diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index e8b07a82..dd586d5a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -585,7 +585,6 @@ "Start Migration": "Iniciar Migração", "Advanced Settings": "Configurações Avançadas", "File count: {{size}}": "Contagem de arquivos: {{size}}", - "Background Read Aloud": "Leitura em Segundo Plano", "Screen Brightness": "Brilho da Tela", "Background Image": "Imagem de Fundo", "Import Image": "Importar Imagem", diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 0fb0942b..ad638cf8 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -162,7 +162,6 @@ "Always on Top": "Întotdeauna deasupra", "Always Show Status Bar": "Afișați întotdeauna bara de stare", "Keep Screen Awake": "Păstrați ecranul activ", - "Background Read Aloud": "Citire cu voce tare în fundal", "Reload Page": "Reîncarcă pagina", "Settings": "Setări", "Advanced Settings": "Setări avansate", diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 02314bb0..bc93133c 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -589,7 +589,6 @@ "Start Migration": "Начать миграцию", "Advanced Settings": "Расширенные настройки", "File count: {{size}}": "Количество файлов: {{size}}", - "Background Read Aloud": "Озвучивание в фоне", "Screen Brightness": "Яркость экрана", "Background Image": "Фоновое изображение", "Import Image": "Импортировать изображение", diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 2aa1d317..b26a7d6d 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -581,7 +581,6 @@ "Start Migration": "මැතිවරණය ආරම්භ කරන්න", "Advanced Settings": "උසස් සැකසුම්", "File count: {{size}}": "ගොනු ගණන: {{size}}", - "Background Read Aloud": "පසුබිම් පවසා කියවීම", "Screen Brightness": "තිරයේ දිදුලනුම", "Background Image": "පසුබිම් රූපය", "Import Image": "රූපය ආයාත කරන්න", diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 36a4e759..aa63e271 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -147,7 +147,6 @@ "Always on Top": "Vedno na vrhu", "Always Show Status Bar": "Vedno prikaži vrstico stanja", "Keep Screen Awake": "Prepreči spanje zaslona", - "Background Read Aloud": "Branje na glas v ozadju", "Reload Page": "Ponovno naloži stran", "Settings": "Nastavitve", "Advanced Settings": "Napredne nastavitve", diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index c61954e3..4dfbffd9 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -111,7 +111,6 @@ "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", diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index 2fad0d9d..8174c27b 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -581,7 +581,6 @@ "Start Migration": "மொத்தமாக்கலை தொடங்கவும்", "Advanced Settings": "மேம்பட்ட அமைப்புகள்", "File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}", - "Background Read Aloud": "பின்னணி உரை வாசிப்பு", "Screen Brightness": "திரை பிரகாசம்", "Background Image": "பின்னணி படம்", "Import Image": "புகைப்படம் இறக்குமதி", diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index c3edaeb2..487fa6a4 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -577,7 +577,6 @@ "Start Migration": "เริ่มการย้ายข้อมูล", "Advanced Settings": "การตั้งค่าขั้นสูง", "File count: {{size}}": "จำนวนไฟล์: {{size}}", - "Background Read Aloud": "อ่านออกเสียงเบื้องหลัง", "Screen Brightness": "ความสว่างหน้าจอ", "Background Image": "ภาพพื้นหลัง", "Import Image": "นำเข้าภาพ", diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index b1f4321e..bc586c86 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -581,7 +581,6 @@ "Start Migration": "Taşımayı Başlat", "Advanced Settings": "Gelişmiş Ayarlar", "File count: {{size}}": "Dosya sayısı: {{size}}", - "Background Read Aloud": "Arka Planda Sesli Okuma", "Screen Brightness": "Ekran Parlaklığı", "Background Image": "Arka Plan Görüntüsü", "Import Image": "Görüntü İçe Aktar", diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index b273095d..3e05bf80 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -589,7 +589,6 @@ "Start Migration": "Почати переміщення", "Advanced Settings": "Розширені налаштування", "File count: {{size}}": "Кількість файлів: {{size}}", - "Background Read Aloud": "Озвучування у фоновому режимі", "Screen Brightness": "Яскравість екрану", "Background Image": "Фонове зображення", "Import Image": "Імпортувати зображення", diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 57c4ac8d..e8e8d203 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -160,7 +160,6 @@ "Fullscreen": "Toʻliq ekran", "Always on Top": "Doimo yuqorida", "Always Show Status Bar": "Holat panelini doimo koʻrsatish", - "Background Read Aloud": "Fonda ovoz chiqarib oʻqish", "Settings": "Sozlamalar", "Advanced Settings": "Kengaytirilgan sozlamalar", "Refresh Metadata": "Metamaʼlumotlarni yangilash", diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 34ced4a2..04598f3a 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -577,7 +577,6 @@ "Start Migration": "Bắt đầu di chuyển", "Advanced Settings": "Cài đặt nâng cao", "File count: {{size}}": "Số lượng tệp: {{size}}", - "Background Read Aloud": "Đọc To Nền", "Screen Brightness": "Độ sáng màn hình", "Background Image": "Ảnh nền", "Import Image": "Nhập ảnh", diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index a8ea044d..95e6302c 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -578,7 +578,6 @@ "Start Migration": "开始迁移", "Advanced Settings": "高级设置", "File count: {{size}}": "文件数量: {{size}}", - "Background Read Aloud": "后台语音朗读", "Screen Brightness": "屏幕亮度", "Background Image": "背景图片", "Import Image": "导入图片", diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index b85e6fe2..10b6e785 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -577,7 +577,6 @@ "Start Migration": "開始遷移", "Advanced Settings": "高級設置", "File count: {{size}}": "文件數量: {{size}}", - "Background Read Aloud": "背景朗讀", "Screen Brightness": "螢幕亮度", "Background Image": "背景圖片", "Import Image": "匯入圖片", diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt index f5516b2b..2dbb93eb 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/MediaPlaybackService.kt @@ -7,6 +7,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.os.Build import android.os.Bundle import android.os.Handler @@ -29,14 +30,12 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import app.tauri.plugin.JSObject -import kotlinx.coroutines.* 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 serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // True only between session activation (TTS playback started) and // deactivation. Android Auto can bind this service at any time to browse, @@ -73,6 +72,15 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { var currentArtist: String = "Reading your content" var currentArtwork: Bitmap? = null + // Estimated section timeline (Edge/WebAudio engine only) in milliseconds. + // Drives the lock-screen scrubber: position is the thumb, duration is + // the track length. Native TextToSpeech has no timeline and leaves + // duration at 0, so the scrubber simply does not appear there. + @Volatile + var currentPositionMs: Long = 0L + @Volatile + var currentDurationMs: Long = 0L + @Volatile private var instance: MediaPlaybackService? = null @@ -85,6 +93,33 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { val service = instance ?: return Handler(Looper.getMainLooper()).post { service.deactivateSession() } } + + // Deliver metadata/state updates to the live service in-process rather + // than via startService(): once the app is backgrounded, startService() + // is rejected with "app is in background" (the Android 8+ background + // service-start restriction), which silently dropped every playback + // update — killing the lock-screen control and the notification refresh + // that keeps the foreground service alive. A direct call on the running + // instance is not a service *start*, so it is exempt. The statics are + // refreshed regardless so a not-yet-created service picks them up when + // it activates. + fun pushMetadata(title: String, artist: String, artwork: Bitmap?) { + currentTitle = title + currentArtist = artist + if (artwork != null) currentArtwork = artwork + val service = instance ?: return + Handler(Looper.getMainLooper()).post { service.applyMetadata() } + } + + // position/duration are null when the update only reports a play/pause + // flip (that payload omits them); keep the last known values so the + // scrubber does not snap back to 0 on pause. + fun pushPlaybackState(playing: Boolean, position: Long?, duration: Long?) { + if (position != null) currentPositionMs = position + if (duration != null) currentDurationMs = duration + val service = instance ?: return + Handler(Looper.getMainLooper()).post { service.applyPlaybackState(playing) } + } } override fun onCreate() { @@ -102,6 +137,7 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or + PlaybackStateCompat.ACTION_SEEK_TO or PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH ) @@ -121,6 +157,7 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { } private fun activateSession() { + Log.d("MediaPlaybackService", "activateSession (wasActive=$sessionActive)") if (!sessionActive) { sessionActive = true @@ -193,6 +230,18 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { pluginEventTrigger?.invoke("media-session-previous", JSObject()) } + // Scrubber drag: hand the target back to the JS controller, which owns + // the real audio timeline (seekToTime), and optimistically move the + // thumb so the lock screen feels responsive before the seek lands. + override fun onSeekTo(pos: Long) { + currentPositionMs = pos + pluginEventTrigger?.invoke("media-session-seek", JSObject().apply { put("position", pos) }) + val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED + mediaSession?.setPlaybackState( + stateBuilder.setState(state, pos, 1f).build() + ) + } + override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { onPlay() } @@ -211,12 +260,78 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { showNotification(state) } + // Last section duration written to the session metadata; the scrubber only + // needs a metadata refresh when it actually changes (per section), not on + // every position tick. + private var appliedDurationMs: Long = -1L + + private fun buildMediaMetadata(): MediaMetadataCompat { + return MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentDurationMs) + .build() + } + + // Push the current statics into the live session + notification. Invoked + // in-process from Companion.pushMetadata on the main thread. + private fun applyMetadata() { + appliedDurationMs = currentDurationMs + mediaSession?.setMetadata(buildMediaMetadata()) + notifyChildrenChanged(MEDIA_ROOT_ID) + if (sessionActive) { + showNotification( + if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING + else PlaybackStateCompat.STATE_PAUSED + ) + } + } + + // Reflect the WebView/TextToSpeech playback state onto the silent + // keep-alive player, the media session, and the notification. Invoked + // in-process from Companion.pushPlaybackState on the main thread; reads the + // preserved position/duration statics. + private fun applyPlaybackState(playing: Boolean) { + if (!sessionActive) return + if (playing && !player.isPlaying) { + player.play() + } else if (!playing && player.isPlaying) { + player.pause() + } + player.seekTo(currentPositionMs) + val state = if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED + mediaSession?.setPlaybackState( + stateBuilder.setState(state, currentPositionMs, 1f).build() + ) + // Refresh the scrubber length when the section duration changes. + if (currentDurationMs != appliedDurationMs) { + appliedDurationMs = currentDurationMs + mediaSession?.setMetadata(buildMediaMetadata()) + } + 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)) + // Promote with an explicit mediaPlayback type (required/robust on + // targetSdk 34+); ServiceCompat handles the pre-Q signature. A throw + // here means the service never becomes foreground and the OS reclaims + // it on idle, so surface it loudly instead of swallowing. + try { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + buildNotification(playbackState), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK, + ) + Log.d("MediaPlaybackService", "startForeground ok (state=$playbackState)") + } catch (e: Exception) { + Log.e("MediaPlaybackService", "startForeground failed", e) + } } private fun buildNotification(playbackState: Int): Notification { @@ -326,56 +441,6 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { stopSelf(startId) } } - "UPDATE_METADATA" -> { - currentTitle = intent.getStringExtra("title") ?: currentTitle - currentArtist = intent.getStringExtra("artist") ?: currentArtist - // Unmarshal the artwork Bitmap off the main thread; copying its - // pixel buffer out of the Parcel can stall the UI thread and trip - // an ANR for large covers. - serviceScope.launch { - val newArtwork = withContext(Dispatchers.Default) { - 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 - } - if (!isActive) return@launch - 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()) - notifyChildrenChanged(MEDIA_ROOT_ID) - if (sessionActive) { - showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED) - } - } - } - "UPDATE_PLAYBACK_STATE" -> { - if (sessionActive) { - 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) @@ -383,7 +448,6 @@ class MediaPlaybackService : MediaBrowserServiceCompat() { override fun onDestroy() { instance = null - serviceScope.cancel() super.onDestroy() player.release() mediaSession?.release() diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt index 08935d20..9f0e9e24 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt @@ -95,7 +95,6 @@ class UpdateMediaSessionStateArgs { @InvokeArg class SetMediaSessionActiveArgs { var active: Boolean? = null - var keepAppInForeground: Boolean? = null var notificationTitle: String? = null var notificationText: String? = null var foregroundServiceTitle: String? = null @@ -497,19 +496,13 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { val args = invoke.parseArgs(UpdateMediaSessionMetadataArgs::class.java) val title = args.title ?: "" val artist = args.artist ?: "" - val album = args.album ?: "" coroutineScope.launch { try { 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) + // In-process update on the running service; never startService() + // — that throws "app is in background" once backgrounded. + MediaPlaybackService.pushMetadata(title, artist, artworkBitmap) invoke.resolve() } catch (e: Exception) { invoke.reject("Failed to update metadata: ${e.message}") @@ -519,19 +512,19 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { @Command fun update_media_session_state(invoke: Invoke) { - var args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java) + val args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java) val isPlaying = args.playing ?: false - val position = args.position ?: 0 - val duration = args.duration ?: 0 try { - val intent = Intent(activity, MediaPlaybackService::class.java).apply { - action = "UPDATE_PLAYBACK_STATE" - putExtra("playing", isPlaying) - putExtra("position", position) - putExtra("duration", duration) - } - activity.startService(intent) + // In-process update on the running service; never startService() + // — that throws "app is in background" once backgrounded. position + // and duration are null on a bare play/pause flip; the service + // keeps the last known values so the scrubber does not reset. + MediaPlaybackService.pushPlaybackState( + isPlaying, + args.position?.toLong(), + args.duration?.toLong(), + ) invoke.resolve() } catch (e: Exception) { invoke.reject("Failed to update playback state: ${e.message}") @@ -557,6 +550,7 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { val intent = Intent(activity, MediaPlaybackService::class.java).apply { action = MediaPlaybackService.ACTION_ACTIVATE_SESSION } + Log.d(TAG, "set_media_session_active: startForegroundService") ContextCompat.startForegroundService(activity, intent) } else { // Not stopService: Android Auto may keep the service bound for diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/ios/Sources/NativeTTSPlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/ios/Sources/NativeTTSPlugin.swift index 39898946..3f5c5adc 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/ios/Sources/NativeTTSPlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/ios/Sources/NativeTTSPlugin.swift @@ -37,7 +37,6 @@ class UpdateMediaSessionStateArgs: Decodable { class SetMediaSessionActiveArgs: Decodable { let active: Bool? - let keepAppInForeground: Bool? let notificationTitle: String? let notificationText: String? let foregroundServiceTitle: String? diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs index ec1055f8..b1a03828 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/src/models.rs @@ -66,7 +66,6 @@ pub struct GetVoicesResponse { #[serde(rename_all = "camelCase")] pub struct SetMediaSessionActiveRequest { pub active: bool, - pub keep_app_in_foreground: bool, pub notification_title: Option, pub notification_text: Option, pub foreground_service_title: Option, diff --git a/apps/readest-app/src/__tests__/libs/mediaSession.test.ts b/apps/readest-app/src/__tests__/libs/mediaSession.test.ts index 53e84649..f938ce8e 100644 --- a/apps/readest-app/src/__tests__/libs/mediaSession.test.ts +++ b/apps/readest-app/src/__tests__/libs/mediaSession.test.ts @@ -16,6 +16,7 @@ vi.mock('@tauri-apps/api/core', () => ({ addPluginListener: vi.fn(), })); +import { invoke } from '@tauri-apps/api/core'; import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession'; import { getOSPlatform } from '@/utils/misc'; import { isTauriAppPlatform } from '@/services/environment'; @@ -83,3 +84,67 @@ describe('getMediaSession', () => { expect(getMediaSession()).toBeNull(); }); }); + +describe('TauriMediaSession.setActive', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('requests POST_NOTIFICATIONS whenever the session activates', async () => { + // The foreground-service media notification IS the lock-screen control; on + // Android 13+ it is silently suppressed unless POST_NOTIFICATIONS is + // granted. The request must fire on every activation (it used to be gated + // on an opt-in setting, which left the control missing by default). + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + return { postNotification: 'prompt' } as unknown; + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|checkPermissions'); + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|requestPermissions', { + permissions: ['postNotification'], + }); + }); + + test('still activates the native session when the permission request throws', async () => { + // A thrown/hung permission request must never abort the foreground-service + // start, or the service never becomes foreground and the OS reclaims it on + // idle (observed on MIUI: "Stopping service due to app idle"). + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + throw new Error('permission plugin unavailable'); + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|set_media_session_active', { + payload: { active: true }, + }); + }); + + test('does not re-prompt once the permission is already decided', async () => { + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'plugin:native-tts|checkPermissions') { + return { postNotification: 'granted' } as unknown; + } + return undefined as unknown; + }); + + const session = new TauriMediaSession(); + await session.setActive({ active: true }); + + expect(invoke).toHaveBeenCalledWith('plugin:native-tts|checkPermissions'); + expect(invoke).not.toHaveBeenCalledWith( + 'plugin:native-tts|requestPermissions', + expect.anything(), + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index 610b0de2..af9b0d95 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -230,7 +230,6 @@ describe('services/constants', () => { expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysOnTop).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.openBookInNewWindow).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysShowStatusBar).toBe('boolean'); - expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysInForeground).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.autoCheckUpdates).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.screenWakeLock).toBe('boolean'); expect(typeof DEFAULT_SYSTEM_SETTINGS.openLastBooks).toBe('boolean'); diff --git a/apps/readest-app/src/__tests__/services/tts-pcm.test.ts b/apps/readest-app/src/__tests__/services/tts-pcm.test.ts index 314efdac..26dd27d9 100644 --- a/apps/readest-app/src/__tests__/services/tts-pcm.test.ts +++ b/apps/readest-app/src/__tests__/services/tts-pcm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { findSpeechBounds } from '@/services/tts/pcm'; +import { applyEdgeFade, findSpeechBounds } from '@/services/tts/pcm'; const SR = 24000; @@ -66,3 +66,41 @@ describe('findSpeechBounds', () => { expect(endSec).toBeCloseTo(0.5, 2); }); }); + +describe('applyEdgeFade', () => { + test('ramps the first and last samples to zero, leaving the interior intact', () => { + const sr = 48000; + const fadeSec = 0.003; + const n = Math.floor(fadeSec * sr); + const samples = new Float32Array(sr).fill(1); // 1s of DC, edges are the clicks + applyEdgeFade(samples, sr, fadeSec); + + // Both edges land exactly on zero, so there is no step from/to silence. + expect(samples[0]).toBe(0); + expect(samples[samples.length - 1]).toBe(0); + // Everything past the fade window is untouched. + expect(samples[n]).toBe(1); + expect(samples[samples.length - 1 - n]).toBe(1); + expect(samples[samples.length >> 1]).toBe(1); + // The fade-in is a monotonic ramp. + for (let i = 1; i < n; i++) { + expect(samples[i]!).toBeGreaterThan(samples[i - 1]!); + } + }); + + test('clamps the ramp so the two ends never overlap on a short buffer', () => { + const samples = new Float32Array([1, 1, 1, 1]); // fade window would exceed length + applyEdgeFade(samples, 48000, 0.003); + expect(samples[0]).toBe(0); + expect(samples[3]).toBe(0); + // No sample double-scaled or amplified. + for (const v of samples) expect(Math.abs(v)).toBeLessThanOrEqual(1); + }); + + test('is a no-op on buffers too small to fade', () => { + expect(() => applyEdgeFade(new Float32Array(0), 48000)).not.toThrow(); + const one = new Float32Array([0.5]); + applyEdgeFade(one, 48000); + expect(one[0]).toBe(0.5); + }); +}); diff --git a/apps/readest-app/src/__tests__/store/settings-store.test.ts b/apps/readest-app/src/__tests__/store/settings-store.test.ts index 93260341..4fcbb97c 100644 --- a/apps/readest-app/src/__tests__/store/settings-store.test.ts +++ b/apps/readest-app/src/__tests__/store/settings-store.test.ts @@ -38,7 +38,6 @@ function makeSettings(overrides: Partial = {}): SystemSettings { screenBrightness: 1, autoScreenBrightness: true, alwaysShowStatusBar: false, - alwaysInForeground: false, openLastBooks: false, lastOpenBooks: [], autoImportBooksOnOpen: false, diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 4667212a..d5d465ac 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -6,7 +6,6 @@ import { PiSun, PiMoon } from 'react-icons/pi'; import { TbSunMoon } from 'react-icons/tb'; import { MdCloudSync, MdSync, MdSyncProblem } from 'react-icons/md'; -import { invoke, PermissionState } from '@tauri-apps/api/core'; import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment'; import { DOWNLOAD_READEST_URL } from '@/services/constants'; import { setBackupDialogVisible } from '@/app/library/components/BackupWindow'; @@ -50,11 +49,6 @@ interface SettingsMenuProps { setIsDropdownOpen?: (isOpen: boolean) => void; } -interface Permissions { - postNotification: PermissionState; - manageStorage: PermissionState; -} - const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdownOpen }) => { const _ = useTranslation(); const router = useRouter(); @@ -70,7 +64,6 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const [isAutoImportBooksOnOpen, setIsAutoImportBooksOnOpen] = useState( settings.autoImportBooksOnOpen, ); - const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground); const [savedBookCoverForLockScreen, setSavedBookCoverForLockScreen] = useState( settings.savedBookCoverForLockScreen || '', ); @@ -266,23 +259,6 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setSavedBookCoverForLockScreen(newValue); }; - const toggleAlwaysInForeground = async () => { - const requestAlwaysInForeground = !settings.alwaysInForeground; - - if (requestAlwaysInForeground) { - let permission = await invoke('plugin:native-tts|checkPermissions'); - if (permission.postNotification !== 'granted') { - permission = await invoke('plugin:native-tts|requestPermissions', { - permissions: ['postNotification'], - }); - } - if (permission.postNotification !== 'granted') return; - } - - saveSysSettings(envConfig, 'alwaysInForeground', requestAlwaysInForeground); - setAlwaysInForeground(requestAlwaysInForeground); - }; - const handleSyncLibrary = () => { onPullLibrary(true, true); setIsDropdownOpen?.(false); @@ -446,13 +422,6 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow onClick={toggleAlwaysShowStatusBar} /> )} - {appService?.isAndroidApp && ( - - )} = { alwaysOnTop: false, openBookInNewWindow: true, alwaysShowStatusBar: false, - alwaysInForeground: false, autoCheckUpdates: true, updateChannel: 'stable', screenWakeLock: false, diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 88f128f7..2b75e52b 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -8,7 +8,7 @@ import { parseSSMLMarks } from '@/utils/ssml'; import { TTSController } from './TTSController'; import { TTSUtils } from './TTSUtils'; import { findBoundaryIndexAtTime } from './wordHighlight'; -import { findSpeechBounds } from './pcm'; +import { applyEdgeFade, findSpeechBounds } from './pcm'; import { timeStretch } from './timeStretch'; import { calibrateVoiceRate, @@ -396,6 +396,9 @@ export class EdgeTTSClient implements TTSClient { const trimmedDurationSec = trimmed.length / sampleRate; const samples = rate !== 1 ? timeStretch(trimmed, sampleRate, rate) : trimmed; const buffer = await this.#player.createMonoBuffer(samples, sampleRate); + // Silence-trimmed edges sit on non-zero samples; fade the buffer's own copy + // so chunk starts/ends don't click against the inter-sentence gap. + applyEdgeFade(buffer.getChannelData(0), sampleRate); return { buffer, trimStartSec: startSample / sampleRate, trimmedDurationSec }; } diff --git a/apps/readest-app/src/services/tts/pcm.ts b/apps/readest-app/src/services/tts/pcm.ts index c0340530..a72d0b7e 100644 --- a/apps/readest-app/src/services/tts/pcm.ts +++ b/apps/readest-app/src/services/tts/pcm.ts @@ -45,3 +45,30 @@ export const findSpeechBounds = ( const endSec = Math.min(samples.length / sampleRate, (last + 1) / sampleRate + TAIL_PAD_SEC); return { startSec, endSec }; }; + +// ~3ms: below one syllable so it is inaudible on speech, but far longer than a +// single sample so the ramp is smooth. +const EDGE_FADE_SEC = 0.003; + +// Ramp the first and last samples to zero, in place. +// +// Speech buffers are cut at an amplitude threshold, not a zero crossing, so +// each begins and ends on a non-zero sample. An AudioBufferSourceNode steps +// straight from/to silence at its edges, and that discontinuity clicks/pops +// between sentences. A short linear fade removes the step without audibly +// touching the speech. Mutates the passed array, so callers pass a buffer they +// own (never a subarray view of the decoded audio). +export const applyEdgeFade = ( + samples: Float32Array, + sampleRate: number, + fadeSec = EDGE_FADE_SEC, +): void => { + const n = Math.min(Math.floor(fadeSec * sampleRate), Math.floor(samples.length / 2)); + if (n <= 0) return; + const last = samples.length - 1; + for (let i = 0; i < n; i++) { + const gain = i / n; + samples[i]! *= gain; + samples[last - i]! *= gain; + } +}; diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index e598b315..7bdcb888 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -295,7 +295,6 @@ export interface SystemSettings { swipeBrightnessGesture: boolean; hardwarePageTurner: HardwarePageTurnerSettings; alwaysShowStatusBar: boolean; - alwaysInForeground: boolean; openLastBooks: boolean; lastOpenBooks: string[]; autoImportBooksOnOpen: boolean;