forked from akai/readest
fix(android): background TTS media controls + lock-screen scrubber/seek + Edge click fix (#4994)
* fix(android): keep background TTS media controls alive when backgrounded The media-session update commands pushed metadata and playback state to MediaPlaybackService via Context.startService(), which Android 8+ rejects with "app is in background" once the app leaves the foreground. Every per-sentence update then failed, so the lock-screen control went stale and the foreground service lost the notification refresh that keeps it alive, dropping background playback. Deliver updates to the running service in-process (the pattern the existing requestDeactivation() already uses) so they can never trigger a service start. Also request POST_NOTIFICATIONS whenever the session activates, not only when alwaysInForeground is set, so the foreground-service media notification (the lock-screen control) is not suppressed on Android 13+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(android): show section duration and enable seek on the TTS media session The Edge/WebAudio TTS engine already reports an estimated section timeline (position and duration) to the media session on every sentence, but the Android service never surfaced it: the lock screen had no scrubber and no seek. Set METADATA_KEY_DURATION so the scrubber shows its length, add ACTION_SEEK_TO, and handle onSeekTo by handing the target back to the JS controller (seekToTime) through the existing media-session-seek event. Playback-state updates that only flip play/pause omit position and duration, so the service now preserves the last known values instead of resetting the scrubber to 0. Native TextToSpeech has no timeline (duration stays 0), so the scrubber simply does not appear there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): harden TTS foreground-service promotion + add diagnostics On MIUI the media service was reclaimed on idle ("Stopping service due to app idle"), which means startForeground never promoted it to a real foreground service. Promote with ServiceCompat and an explicit FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK type (robust on targetSdk 34+) and log any failure instead of swallowing it. Decouple the POST_NOTIFICATIONS request from the service start in setActive: a throwing or hung permission request no longer aborts set_media_session_active, so the foreground service always starts. Add trace logging (set_media_session_active, activateSession, startForeground) to pinpoint the promotion path on-device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): drop required keepAppInForeground so the TTS service starts The set_media_session_active payload required keep_app_in_foreground, but the media bridge activates with just { active: true }. Tauri rejected the invoke at the serde layer ("missing field keepAppInForeground") before the command ran, so the foreground service never started: no media notification, and Android 15 audio hardening then muted background playback. The flag was dead everywhere: no platform read it (the foreground service always starts and the POST_NOTIFICATIONS request is now unconditional). Remove it from the Rust, Kotlin, iOS, and TS payloads. Effective behavior is always-foreground. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(tts): fade sentence-buffer edges to stop Edge TTS clicks The silence trim (findSpeechBounds) cuts at an amplitude threshold, not a zero crossing, so each Edge TTS sentence buffer 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 (WSOLA is not involved: it is a no-op at rate 1.0 and cross-fades its internal splices). Apply a ~3ms linear fade to each buffer's own copy after WSOLA so the edges ramp to zero. The trim and the controlled inter-sentence gap are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(android): remove the dead "Background Read Aloud" setting alwaysInForeground only drove the removed keepAppInForeground flag; nothing reads it now (the foreground service always starts, and POST_NOTIFICATIONS is requested at play time). Remove the setting, its default, the Android library-menu toggle (which just requested the notification permission), and prune the now-unused i18n key from all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -597,7 +597,6 @@
|
||||
"Start Migration": "بدء الترحيل",
|
||||
"Advanced Settings": "إعدادات متقدمة",
|
||||
"File count: {{size}}": "عدد الملفات: {{size}}",
|
||||
"Background Read Aloud": "قراءة بصوت عالٍ في الخلفية",
|
||||
"Screen Brightness": "سطوع الشاشة",
|
||||
"Background Image": "صورة الخلفية",
|
||||
"Import Image": "استيراد صورة",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "মাইগ্রেশন শুরু করুন",
|
||||
"Advanced Settings": "উন্নত সেটিংস",
|
||||
"File count: {{size}}": "ফাইলের সংখ্যা: {{size}}",
|
||||
"Background Read Aloud": "পটভূমিতে উচ্চস্বরে পড়ুন",
|
||||
"Screen Brightness": "স্ক্রিন উজ্জ্বলতা",
|
||||
"Background Image": "ব্যাকগ্রাউন্ড ছবি",
|
||||
"Import Image": "ছবি আমদানি করুন",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"Start Migration": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
|
||||
"Advanced Settings": "དབང་བསྐྱོད་སྒྲིག་སྟངས།",
|
||||
"File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}",
|
||||
"Background Read Aloud": "གནས་སྟངས་ཀྱིས་ཀློག་བྱེད།",
|
||||
"Screen Brightness": "རྒྱབ་སྐོར་གྱི་འོད་ཟེར།",
|
||||
"Background Image": "གནས་སྟངས་ཀྱི་རི་མོ།",
|
||||
"Import Image": "དེབ་གནས་བཅས་པའི་སྤྱོད་བྱས་མ་ཐུབ།",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "Έναρξη μετανάστευσης",
|
||||
"Advanced Settings": "Προηγμένες ρυθμίσεις",
|
||||
"File count: {{size}}": "Αριθμός αρχείων: {{size}}",
|
||||
"Background Read Aloud": "Ανάγνωση στο παρασκήνιο",
|
||||
"Screen Brightness": "Φωτεινότητα οθόνης",
|
||||
"Background Image": "Εικόνα φόντου",
|
||||
"Import Image": "Εισαγωγή εικόνας",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "شروع انتقال",
|
||||
"Advanced Settings": "تنظیمات پیشرفته",
|
||||
"File count: {{size}}": "تعداد فایلها: {{size}}",
|
||||
"Background Read Aloud": "خواندن با صدای بلند در پسزمینه",
|
||||
"Screen Brightness": "روشنایی صفحه",
|
||||
"Background Image": "تصویر پسزمینه",
|
||||
"Import Image": "وارد کردن تصویر",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -144,7 +144,6 @@
|
||||
"Always on Top": "תמיד למעלה",
|
||||
"Always Show Status Bar": "תמיד הצג שורת סטטוס",
|
||||
"Keep Screen Awake": "השאר מסך פעיל",
|
||||
"Background Read Aloud": "הקראה ברקע",
|
||||
"Reload Page": "טען דף מחדש",
|
||||
"Settings": "הגדרות",
|
||||
"Advanced Settings": "הגדרות מתקדמות",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "माइग्रेशन प्रारंभ करें",
|
||||
"Advanced Settings": "उन्नत सेटिंग्स",
|
||||
"File count: {{size}}": "फ़ाइलों की संख्या: {{size}}",
|
||||
"Background Read Aloud": "पृष्ठभूमि में वाचन",
|
||||
"Screen Brightness": "स्क्रीन ब्राइटनेस",
|
||||
"Background Image": "पृष्ठभूमि छवि",
|
||||
"Import Image": "छवि आयात करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"Start Migration": "移行を開始",
|
||||
"Advanced Settings": "詳細設定",
|
||||
"File count: {{size}}": "ファイル数: {{size}}",
|
||||
"Background Read Aloud": "バックグラウンド読み上げ",
|
||||
"Screen Brightness": "画面の明るさ",
|
||||
"Background Image": "背景画像",
|
||||
"Import Image": "画像をインポート",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"Start Migration": "마이그레이션 시작",
|
||||
"Advanced Settings": "고급 설정",
|
||||
"File count: {{size}}": "파일 수: {{size}}",
|
||||
"Background Read Aloud": "백그라운드 음성 읽기",
|
||||
"Screen Brightness": "화면 밝기",
|
||||
"Background Image": "배경 이미지",
|
||||
"Import Image": "이미지 가져오기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -589,7 +589,6 @@
|
||||
"Start Migration": "Начать миграцию",
|
||||
"Advanced Settings": "Расширенные настройки",
|
||||
"File count: {{size}}": "Количество файлов: {{size}}",
|
||||
"Background Read Aloud": "Озвучивание в фоне",
|
||||
"Screen Brightness": "Яркость экрана",
|
||||
"Background Image": "Фоновое изображение",
|
||||
"Import Image": "Импортировать изображение",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "මැතිවරණය ආරම්භ කරන්න",
|
||||
"Advanced Settings": "උසස් සැකසුම්",
|
||||
"File count: {{size}}": "ගොනු ගණන: {{size}}",
|
||||
"Background Read Aloud": "පසුබිම් පවසා කියවීම",
|
||||
"Screen Brightness": "තිරයේ දිදුලනුම",
|
||||
"Background Image": "පසුබිම් රූපය",
|
||||
"Import Image": "රූපය ආයාත කරන්න",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -581,7 +581,6 @@
|
||||
"Start Migration": "மொத்தமாக்கலை தொடங்கவும்",
|
||||
"Advanced Settings": "மேம்பட்ட அமைப்புகள்",
|
||||
"File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}",
|
||||
"Background Read Aloud": "பின்னணி உரை வாசிப்பு",
|
||||
"Screen Brightness": "திரை பிரகாசம்",
|
||||
"Background Image": "பின்னணி படம்",
|
||||
"Import Image": "புகைப்படம் இறக்குமதி",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"Start Migration": "เริ่มการย้ายข้อมูล",
|
||||
"Advanced Settings": "การตั้งค่าขั้นสูง",
|
||||
"File count: {{size}}": "จำนวนไฟล์: {{size}}",
|
||||
"Background Read Aloud": "อ่านออกเสียงเบื้องหลัง",
|
||||
"Screen Brightness": "ความสว่างหน้าจอ",
|
||||
"Background Image": "ภาพพื้นหลัง",
|
||||
"Import Image": "นำเข้าภาพ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -589,7 +589,6 @@
|
||||
"Start Migration": "Почати переміщення",
|
||||
"Advanced Settings": "Розширені налаштування",
|
||||
"File count: {{size}}": "Кількість файлів: {{size}}",
|
||||
"Background Read Aloud": "Озвучування у фоновому режимі",
|
||||
"Screen Brightness": "Яскравість екрану",
|
||||
"Background Image": "Фонове зображення",
|
||||
"Import Image": "Імпортувати зображення",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -578,7 +578,6 @@
|
||||
"Start Migration": "开始迁移",
|
||||
"Advanced Settings": "高级设置",
|
||||
"File count: {{size}}": "文件数量: {{size}}",
|
||||
"Background Read Aloud": "后台语音朗读",
|
||||
"Screen Brightness": "屏幕亮度",
|
||||
"Background Image": "背景图片",
|
||||
"Import Image": "导入图片",
|
||||
|
||||
@@ -577,7 +577,6 @@
|
||||
"Start Migration": "開始遷移",
|
||||
"Advanced Settings": "高級設置",
|
||||
"File count: {{size}}": "文件數量: {{size}}",
|
||||
"Background Read Aloud": "背景朗讀",
|
||||
"Screen Brightness": "螢幕亮度",
|
||||
"Background Image": "背景圖片",
|
||||
"Import Image": "匯入圖片",
|
||||
|
||||
+118
-54
@@ -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()
|
||||
|
||||
+14
-20
@@ -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
|
||||
|
||||
-1
@@ -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?
|
||||
|
||||
@@ -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<String>,
|
||||
pub notification_text: Option<String>,
|
||||
pub foreground_service_title: Option<String>,
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ function makeSettings(overrides: Partial<SystemSettings> = {}): SystemSettings {
|
||||
screenBrightness: 1,
|
||||
autoScreenBrightness: true,
|
||||
alwaysShowStatusBar: false,
|
||||
alwaysInForeground: false,
|
||||
openLastBooks: false,
|
||||
lastOpenBooks: [],
|
||||
autoImportBooksOnOpen: false,
|
||||
|
||||
@@ -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<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -70,7 +64,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
setSavedBookCoverForLockScreen(newValue);
|
||||
};
|
||||
|
||||
const toggleAlwaysInForeground = async () => {
|
||||
const requestAlwaysInForeground = !settings.alwaysInForeground;
|
||||
|
||||
if (requestAlwaysInForeground) {
|
||||
let permission = await invoke<Permissions>('plugin:native-tts|checkPermissions');
|
||||
if (permission.postNotification !== 'granted') {
|
||||
permission = await invoke<Permissions>('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<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
onClick={toggleAlwaysShowStatusBar}
|
||||
/>
|
||||
)}
|
||||
{appService?.isAndroidApp && (
|
||||
<MenuItem
|
||||
label={_(_('Background Read Aloud'))}
|
||||
toggled={alwaysInForeground}
|
||||
onClick={toggleAlwaysInForeground}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
label={themeModeLabel}
|
||||
Icon={themeMode === 'dark' ? PiMoon : themeMode === 'light' ? PiSun : TbSunMoon}
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface PlaybackState {
|
||||
|
||||
export interface MediaSessionState {
|
||||
active: boolean;
|
||||
keepAppInForeground?: boolean;
|
||||
notificationTitle?: string;
|
||||
notificationText?: string;
|
||||
foregroundServiceTitle?: string;
|
||||
@@ -113,15 +112,30 @@ export class TauriMediaSession {
|
||||
}
|
||||
|
||||
async setActive(sessionState: MediaSessionState) {
|
||||
try {
|
||||
if (sessionState.active) {
|
||||
if (sessionState.keepAppInForeground) {
|
||||
await this.requestPostNotificationPermission();
|
||||
}
|
||||
await this.initializeListeners();
|
||||
} else {
|
||||
await this.cleanupListeners();
|
||||
if (sessionState.active) {
|
||||
// The foreground-service media notification IS the lock-screen control;
|
||||
// on Android 13+ it is silently suppressed unless POST_NOTIFICATIONS is
|
||||
// granted. Request it on every activation (no-op once decided).
|
||||
// Best-effort: it must never block or abort the foreground-service start
|
||||
// below, so it gets its own catch.
|
||||
try {
|
||||
await this.requestPostNotificationPermission();
|
||||
} catch (error) {
|
||||
console.warn('POST_NOTIFICATIONS request failed:', error);
|
||||
}
|
||||
try {
|
||||
await this.initializeListeners();
|
||||
} catch (error) {
|
||||
console.warn('Media session listener init failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.cleanupListeners();
|
||||
} catch (error) {
|
||||
console.warn('Media session listener cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await invoke('plugin:native-tts|set_media_session_active', {
|
||||
payload: sessionState,
|
||||
});
|
||||
|
||||
@@ -135,7 +135,6 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
alwaysOnTop: false,
|
||||
openBookInNewWindow: true,
|
||||
alwaysShowStatusBar: false,
|
||||
alwaysInForeground: false,
|
||||
autoCheckUpdates: true,
|
||||
updateChannel: 'stable',
|
||||
screenWakeLock: false,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -295,7 +295,6 @@ export interface SystemSettings {
|
||||
swipeBrightnessGesture: boolean;
|
||||
hardwarePageTurner: HardwarePageTurnerSettings;
|
||||
alwaysShowStatusBar: boolean;
|
||||
alwaysInForeground: boolean;
|
||||
openLastBooks: boolean;
|
||||
lastOpenBooks: string[];
|
||||
autoImportBooksOnOpen: boolean;
|
||||
|
||||
Reference in New Issue
Block a user