feat: supported background TTS with media session controls, closes #2099 and closes #964 (#2138)

This commit is contained in:
Huang Xin
2025-09-30 01:45:17 +08:00
committed by GitHub
parent 25b44176b9
commit 0a1e0212e2
49 changed files with 1105 additions and 57 deletions
@@ -612,5 +612,6 @@
"Restart App": "إعادة تشغيل التطبيق",
"Start Migration": "بدء الترحيل",
"Advanced Settings": "إعدادات متقدمة",
"File count: {{size}}": "عدد الملفات: {{size}}"
"File count: {{size}}": "عدد الملفات: {{size}}",
"Background Read Aloud": "قراءة بصوت عالٍ في الخلفية"
}
@@ -596,5 +596,6 @@
"Restart App": "অ্যাপ পুনরায় চালু করুন",
"Start Migration": "মাইগ্রেশন শুরু করুন",
"Advanced Settings": "উন্নত সেটিংস",
"File count: {{size}}": "ফাইলের সংখ্যা: {{size}}"
"File count: {{size}}": "ফাইলের সংখ্যা: {{size}}",
"Background Read Aloud": "পটভূমিতে উচ্চস্বরে পড়ুন"
}
@@ -592,5 +592,6 @@
"Restart App": "དེབ་ཀྱི་སྤྱོད་བྱས་མ་ཐུབ།",
"Start Migration": "མཉམ་དུ་ཀློག་འཛུལ་བྱས་མ་ཐུབ།",
"Advanced Settings": "དབང་བསྐྱོད་སྒྲིག་སྟངས།",
"File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}"
"File count: {{size}}": "དེབ་གནས་བཅས་པའི་ཨང་། {{size}}",
"Background Read Aloud": "གནས་སྟངས་ཀྱིས་ཀློག་བྱེད།"
}
@@ -596,5 +596,6 @@
"Restart App": "App neu starten",
"Start Migration": "Migration starten",
"Advanced Settings": "Erweiterte Einstellungen",
"File count: {{size}}": "Dateianzahl: {{size}}"
"File count: {{size}}": "Dateianzahl: {{size}}",
"Background Read Aloud": "Hintergrund-Vorlesen"
}
@@ -596,5 +596,6 @@
"Restart App": "Επανεκκίνηση εφαρμογής",
"Start Migration": "Έναρξη μετανάστευσης",
"Advanced Settings": "Προηγμένες ρυθμίσεις",
"File count: {{size}}": "Αριθμός αρχείων: {{size}}"
"File count: {{size}}": "Αριθμός αρχείων: {{size}}",
"Background Read Aloud": "Ανάγνωση στο παρασκήνιο"
}
@@ -600,5 +600,6 @@
"Restart App": "Reiniciar Aplicación",
"Start Migration": "Iniciar Migración",
"Advanced Settings": "Configuraciones Avanzadas",
"File count: {{size}}": "Número de archivos: {{size}}"
"File count: {{size}}": "Número de archivos: {{size}}",
"Background Read Aloud": "Lectura en segundo plano"
}
@@ -600,5 +600,6 @@
"Restart App": "Redémarrer l'Application",
"Start Migration": "Démarrer la Migration",
"Advanced Settings": "Paramètres Avancés",
"File count: {{size}}": "Nombre de fichiers : {{size}}"
"File count: {{size}}": "Nombre de fichiers : {{size}}",
"Background Read Aloud": "Lecture Vocale en Arrière-Plan"
}
@@ -596,5 +596,6 @@
"Restart App": "एप्लिकेशन पुनरारंभ करें",
"Start Migration": "माइग्रेशन प्रारंभ करें",
"Advanced Settings": "उन्नत सेटिंग्स",
"File count: {{size}}": "फ़ाइलों की संख्या: {{size}}"
"File count: {{size}}": "फ़ाइलों की संख्या: {{size}}",
"Background Read Aloud": "पृष्ठभूमि में वाचन"
}
@@ -592,5 +592,6 @@
"Restart App": "Restart Aplikasi",
"Start Migration": "Mulai Migrasi",
"Advanced Settings": "Pengaturan Lanjutan",
"File count: {{size}}": "Jumlah file: {{size}}"
"File count: {{size}}": "Jumlah file: {{size}}",
"Background Read Aloud": "Bacakan Latar Belakang"
}
@@ -600,5 +600,6 @@
"Restart App": "Riavvia App",
"Start Migration": "Inizia Migrazione",
"Advanced Settings": "Impostazioni Avanzate",
"File count: {{size}}": "Conteggio file: {{size}}"
"File count: {{size}}": "Conteggio file: {{size}}",
"Background Read Aloud": "Background Read Aloud"
}
@@ -592,5 +592,6 @@
"Restart App": "アプリを再起動",
"Start Migration": "移行を開始",
"Advanced Settings": "詳細設定",
"File count: {{size}}": "ファイル数: {{size}}"
"File count: {{size}}": "ファイル数: {{size}}",
"Background Read Aloud": "バックグラウンド読み上げ"
}
@@ -592,5 +592,6 @@
"Restart App": "앱 재시작",
"Start Migration": "마이그레이션 시작",
"Advanced Settings": "고급 설정",
"File count: {{size}}": "파일 수: {{size}}"
"File count: {{size}}": "파일 수: {{size}}",
"Background Read Aloud": "백그라운드 음성 읽기"
}
@@ -596,5 +596,6 @@
"Restart App": "Herstart App",
"Start Migration": "Start Migratie",
"Advanced Settings": "Geavanceerde Instellingen",
"File count: {{size}}": "Bestandsaantal: {{size}}"
"File count: {{size}}": "Bestandsaantal: {{size}}",
"Background Read Aloud": "Achtergrond Voorlezen"
}
@@ -604,5 +604,6 @@
"Restart App": "Zrestartuj aplikację",
"Start Migration": "Rozpocznij migrację",
"Advanced Settings": "Ustawienia zaawansowane",
"File count: {{size}}": "Liczba plików: {{size}}"
"File count: {{size}}": "Liczba plików: {{size}}",
"Background Read Aloud": "Czytanie na głos w tle"
}
@@ -600,5 +600,6 @@
"Restart App": "Reiniciar Aplicativo",
"Start Migration": "Iniciar Migração",
"Advanced Settings": "Configurações Avançadas",
"File count: {{size}}": "Contagem de arquivos: {{size}}"
"File count: {{size}}": "Contagem de arquivos: {{size}}",
"Background Read Aloud": "Leitura em Segundo Plano"
}
@@ -604,5 +604,6 @@
"Restart App": "Перезапустить приложение",
"Start Migration": "Начать миграцию",
"Advanced Settings": "Расширенные настройки",
"File count: {{size}}": "Количество файлов: {{size}}"
"File count: {{size}}": "Количество файлов: {{size}}",
"Background Read Aloud": "Озвучивание в фоне"
}
@@ -596,5 +596,6 @@
"Restart App": "යෙදුම නැවත ආරම්භ කරන්න",
"Start Migration": "මැතිවරණය ආරම්භ කරන්න",
"Advanced Settings": "උසස් සැකසුම්",
"File count: {{size}}": "ගොනු ගණන: {{size}}"
"File count: {{size}}": "ගොනු ගණන: {{size}}",
"Background Read Aloud": "පසුබිම් පවසා කියවීම"
}
@@ -596,5 +596,6 @@
"Restart App": "செயலியை மீண்டும் தொடங்கவும்",
"Start Migration": "மொத்தமாக்கலை தொடங்கவும்",
"Advanced Settings": "மேம்பட்ட அமைப்புகள்",
"File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}"
"File count: {{size}}": "கோப்பு எண்ணிக்கை: {{size}}",
"Background Read Aloud": "பின்னணி உரை வாசிப்பு"
}
@@ -592,5 +592,6 @@
"Restart App": "รีสตาร์ทแอป",
"Start Migration": "เริ่มการย้ายข้อมูล",
"Advanced Settings": "การตั้งค่าขั้นสูง",
"File count: {{size}}": "จำนวนไฟล์: {{size}}"
"File count: {{size}}": "จำนวนไฟล์: {{size}}",
"Background Read Aloud": "อ่านออกเสียงเบื้องหลัง"
}
@@ -596,5 +596,6 @@
"Restart App": "Uygulamayı Yeniden Başlat",
"Start Migration": "Taşımayı Başlat",
"Advanced Settings": "Gelişmiş Ayarlar",
"File count: {{size}}": "Dosya sayısı: {{size}}"
"File count: {{size}}": "Dosya sayısı: {{size}}",
"Background Read Aloud": "Arka Planda Sesli Okuma"
}
@@ -604,5 +604,6 @@
"Restart App": "Перезапустити програму",
"Start Migration": "Почати переміщення",
"Advanced Settings": "Розширені налаштування",
"File count: {{size}}": "Кількість файлів: {{size}}"
"File count: {{size}}": "Кількість файлів: {{size}}",
"Background Read Aloud": "Озвучування у фоновому режимі"
}
@@ -592,5 +592,6 @@
"Restart App": "Khởi động lại ứng dụng",
"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}}"
"File count: {{size}}": "Số lượng tệp: {{size}}",
"Background Read Aloud": "Đọc To Nền"
}
@@ -592,5 +592,6 @@
"Restart App": "重启应用",
"Start Migration": "开始迁移",
"Advanced Settings": "高级设置",
"File count: {{size}}": "文件数量: {{size}}"
"File count: {{size}}": "文件数量: {{size}}",
"Background Read Aloud": "后台语音朗读"
}
@@ -592,5 +592,6 @@
"Restart App": "重新啟動應用",
"Start Migration": "開始遷移",
"Advanced Settings": "高級設置",
"File count: {{size}}": "文件數量: {{size}}"
"File count: {{size}}": "文件數量: {{size}}",
"Background Read Aloud": "背景朗讀"
}
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
@@ -33,8 +33,8 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.browser:browser:1.8.0")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
@@ -33,9 +33,11 @@ android {
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("androidx.media3:media3-common:1.2.0")
implementation("androidx.media3:media3-session:1.2.0")
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.7.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
@@ -1,5 +1,6 @@
package com.readest.native_tts
import android.Manifest
import android.os.Bundle
import android.app.Activity
import android.content.Context
@@ -8,8 +9,18 @@ import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.speech.tts.Voice
import android.util.Log
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.ForwardingPlayer
import androidx.media3.session.MediaSession
import androidx.media3.exoplayer.ExoPlayer
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
@@ -25,6 +36,16 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.*
import java.net.URL
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
data class TTSVoiceData(
val id: String,
@@ -60,7 +81,103 @@ class SetVoiceArgs(
val voice: String? = null
)
@TauriPlugin
@InvokeArg
class UpdateMediaSessionMetadataArgs {
var title: String? = null
var artist: String? = null
var album: String? = null
var artwork: String? = null
}
@InvokeArg
class UpdateMediaSessionStateArgs {
var playing: Boolean? = null
var position: Double? = null
var duration: Double? = null
}
@InvokeArg
class SetMediaSessionActiveArgs {
var active: Boolean? = null
}
class MediaForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "tts_media_playback_channel"
const val ACTION_START = "START_FOREGROUND_SERVICE"
const val ACTION_STOP = "STOP_FOREGROUND_SERVICE"
var pluginInstance: NativeTTSPlugin? = null
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
startForegroundService()
}
ACTION_STOP -> {
stopForegroundService()
}
}
return START_STICKY // Restart if killed
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"TTS Media Playback",
NotificationManager.IMPORTANCE_MIN
).apply {
description = "Controls for TTS media playback"
setShowBadge(false)
setSound(null, null)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun startForegroundService() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("TTS Media Session")
.setContentText("Text-to-speech playback active")
.setSmallIcon(android.R.drawable.ic_media_play) // Replace with your icon
.setOngoing(true)
.setSilent(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
startForeground(NOTIFICATION_ID, notification)
Log.d("MediaForegroundService", "Foreground service started")
}
private fun stopForegroundService() {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.d("MediaForegroundService", "Foreground service stopped")
}
override fun onDestroy() {
super.onDestroy()
Log.d("MediaForegroundService", "Service destroyed")
}
}
@TauriPlugin(
permissions = [
Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "postNotification")
]
)
class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
@@ -78,7 +195,15 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
private val eventChannels = ConcurrentHashMap<String, Channel<TTSMessageEvent>>()
private val speakingJobs = ConcurrentHashMap<String, Job>()
private var player: Player? = null
private var mediaSession: MediaSession? = null
private var isMediaSessionActive = false
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
MediaForegroundService.pluginInstance = this
}
@Command
fun init(invoke: Invoke) {
@@ -373,13 +498,236 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) {
invoke.reject("Exception getting voices: ${e.message}")
}
}
@UnstableApi
private class WebViewPlayer(player: Player) : ForwardingPlayer(player) {
var eventTrigger: ((String, JSObject) -> Unit)? = null
override fun play() {
eventTrigger?.invoke("media-session-play", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun pause() {
eventTrigger?.invoke("media-session-pause", JSObject())
if (!this.playWhenReady) {
this.playWhenReady = true
} else {
this.playWhenReady = false
}
}
override fun seekToNext() {
eventTrigger?.invoke("media-session-next", JSObject())
}
override fun seekToPrevious() {
eventTrigger?.invoke("media-session-previous", JSObject())
}
}
private fun initializeMediaSession() {
var basePlayer = ExoPlayer.Builder(activity).build()
val webViewPlayer = WebViewPlayer(basePlayer)
webViewPlayer.eventTrigger = { event, data -> trigger(event, data) }
player = webViewPlayer
mediaSession = MediaSession.Builder(activity, player!!)
.setCallback(object : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val builder = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
builder.setAvailablePlayerCommands(
Player.Commands.Builder()
.add(Player.COMMAND_PLAY_PAUSE)
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
)
return builder.build()
}
})
.build()
}
private fun deinitializeMediaSession() {
mediaSession?.release()
mediaSession = null
player?.release()
player = null
}
private fun startForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
Log.d(TAG, "Foreground service started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service", e)
}
}
private fun stopForegroundService() {
try {
val intent = Intent(activity, MediaForegroundService::class.java).apply {
action = MediaForegroundService.ACTION_STOP
}
activity.stopService(intent)
Log.d(TAG, "Foreground service stopped")
} catch (e: Exception) {
Log.e(TAG, "Failed to stop foreground service", e)
}
}
private suspend fun loadArtworkFromUrl(urlString: String): Bitmap? {
return withContext(Dispatchers.IO) {
try {
when {
urlString.startsWith("data:image/") -> {
val base64Data = urlString.substringAfter("base64,")
val decodedBytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size)
}
urlString.startsWith("http") -> {
val url = URL(urlString)
val input: java.io.InputStream = url.openStream()
BitmapFactory.decodeStream(input)
}
else -> {
val assetPath = urlString.removePrefix("/")
val inputStream = activity.assets.open(assetPath)
BitmapFactory.decodeStream(inputStream)
}
}
} catch (e: Exception) {
null
}
}
}
private var currentArtworkByteArray: ByteArray? = null
@Command
fun update_media_session_metadata(invoke: Invoke) {
val args = invoke.parseArgs(UpdateMediaSessionMetadataArgs::class.java)
val title = args.title ?: ""
val artist = args.artist ?: ""
val album = args.album ?: ""
val artworkUrl = args.artwork ?: ""
coroutineScope.launch {
try {
val metadataBuilder = MediaMetadata.Builder()
.setTitle(title)
.setArtist(artist)
.setAlbumTitle(album)
.apply {
if (artworkUrl.isNotEmpty()) {
loadArtworkFromUrl(artworkUrl)?.let { bitmap ->
java.io.ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
currentArtworkByteArray = stream.toByteArray()
}
}
}
currentArtworkByteArray?.let {
setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
}
}
val mediaItem: MediaItem = MediaItem.Builder()
.setMediaId("silent_media")
.setUri("data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQQAAAAAAA==")
.setMediaMetadata(metadataBuilder.build())
.build()
player?.setMediaItems(listOf(mediaItem, mediaItem))
player?.prepare()
player?.playWhenReady = true
player?.setRepeatMode(Player.REPEAT_MODE_ALL)
invoke.resolve()
} catch (e: Exception) {
Log.e("NativeBridgePlugin", "Failed to update metadata: ${e.message}")
invoke.reject("Failed to update metadata: ${e.message}")
}
}
}
@Command
fun update_media_session_state(invoke: Invoke) {
var args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java)
val isPlaying = args.playing ?: false
val position = args.position ?: 0.0
val duration = args.duration ?: 0.0
try {
player?.let { player ->
if (position > 0) {
player.seekTo((position * 1000).toLong())
}
player.playWhenReady = isPlaying
}
invoke.resolve()
} catch (e: Exception) {
invoke.reject("Failed to update playback state: ${e.message}")
}
}
@Command
fun set_media_session_active(invoke: Invoke) {
var args = invoke.parseArgs(SetMediaSessionActiveArgs::class.java)
val active = args.active ?: true
try {
if (active && !isMediaSessionActive) {
initializeMediaSession()
startForegroundService()
isMediaSessionActive = true
Log.d(TAG, "Media session activated with foreground service")
} else if (!active && isMediaSessionActive) {
deinitializeMediaSession()
stopForegroundService()
isMediaSessionActive = false
Log.d(TAG, "Media session deactivated and foreground service stopped")
}
invoke.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to set media session active state: ${e.message}")
invoke.reject("Failed to set media session active state: ${e.message}")
}
}
fun destroy() {
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
try {
if (isMediaSessionActive) {
stopForegroundService()
isMediaSessionActive = false
}
deinitializeMediaSession()
coroutineScope.cancel()
textToSpeech?.shutdown()
eventChannels.values.forEach { it.close() }
eventChannels.clear()
speakingJobs.values.forEach { it.cancel() }
speakingJobs.clear()
MediaForegroundService.pluginInstance = null
Log.d(TAG, "Plugin destroyed successfully")
} catch (e: Exception) {
Log.e(TAG, "Error during plugin destruction", e)
}
}
}
@@ -8,6 +8,11 @@ const COMMANDS: &[&str] = &[
"set_pitch",
"set_voice",
"get_all_voices",
"set_media_session_active",
"update_media_session_state",
"update_media_session_metadata",
"checkPermissions",
"requestPermissions",
"registerListener",
"remove_listener",
];
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-checkPermissions"
description = "Enables the checkPermissions command without any pre-configured scope."
commands.allow = ["checkPermissions"]
[[permission]]
identifier = "deny-checkPermissions"
description = "Denies the checkPermissions command without any pre-configured scope."
commands.deny = ["checkPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-requestPermissions"
description = "Enables the requestPermissions command without any pre-configured scope."
commands.allow = ["requestPermissions"]
[[permission]]
identifier = "deny-requestPermissions"
description = "Denies the requestPermissions command without any pre-configured scope."
commands.deny = ["requestPermissions"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-set-media-session-active"
description = "Enables the set_media_session_active command without any pre-configured scope."
commands.allow = ["set_media_session_active"]
[[permission]]
identifier = "deny-set-media-session-active"
description = "Denies the set_media_session_active command without any pre-configured scope."
commands.deny = ["set_media_session_active"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update-media-session-metadata"
description = "Enables the update_media_session_metadata command without any pre-configured scope."
commands.allow = ["update_media_session_metadata"]
[[permission]]
identifier = "deny-update-media-session-metadata"
description = "Denies the update_media_session_metadata command without any pre-configured scope."
commands.deny = ["update_media_session_metadata"]
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-update-media-session-state"
description = "Enables the update_media_session_state command without any pre-configured scope."
commands.allow = ["update_media_session_state"]
[[permission]]
identifier = "deny-update-media-session-state"
description = "Denies the update_media_session_state command without any pre-configured scope."
commands.deny = ["update_media_session_state"]
@@ -13,6 +13,11 @@ Default permissions for the plugin
- `allow-set-pitch`
- `allow-set-voice`
- `allow-get-all-voices`
- `allow-set-media-session-active`
- `allow-update-media-session-state`
- `allow-update-media-session-metadata`
- `allow-checkPermissions`
- `allow-requestPermissions`
- `allow-registerListener`
- `allow-remove-listener`
@@ -25,6 +30,32 @@ Default permissions for the plugin
</tr>
<tr>
<td>
`native-tts:allow-checkPermissions`
</td>
<td>
Enables the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-checkPermissions`
</td>
<td>
Denies the checkPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -158,6 +189,32 @@ Denies the remove_listener command without any pre-configured scope.
<tr>
<td>
`native-tts:allow-requestPermissions`
</td>
<td>
Enables the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-requestPermissions`
</td>
<td>
Denies the requestPermissions command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-resume`
</td>
@@ -184,6 +241,32 @@ Denies the resume command without any pre-configured scope.
<tr>
<td>
`native-tts:allow-set-media-session-active`
</td>
<td>
Enables the set_media_session_active command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-set-media-session-active`
</td>
<td>
Denies the set_media_session_active command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-set-pitch`
</td>
@@ -308,6 +391,58 @@ Enables the stop command without any pre-configured scope.
Denies the stop command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-update-media-session-metadata`
</td>
<td>
Enables the update_media_session_metadata command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-update-media-session-metadata`
</td>
<td>
Denies the update_media_session_metadata command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:allow-update-media-session-state`
</td>
<td>
Enables the update_media_session_state command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-tts:deny-update-media-session-state`
</td>
<td>
Denies the update_media_session_state command without any pre-configured scope.
</td>
</tr>
</table>
@@ -10,6 +10,11 @@ permissions = [
"allow-set-pitch",
"allow-set-voice",
"allow-get-all-voices",
"allow-set-media-session-active",
"allow-update-media-session-state",
"allow-update-media-session-metadata",
"allow-checkPermissions",
"allow-requestPermissions",
"allow-registerListener",
"allow-remove-listener",
]
@@ -294,6 +294,18 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-checkPermissions",
"markdownDescription": "Enables the checkPermissions command without any pre-configured scope."
},
{
"description": "Denies the checkPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-checkPermissions",
"markdownDescription": "Denies the checkPermissions command without any pre-configured scope."
},
{
"description": "Enables the get_all_voices command without any pre-configured scope.",
"type": "string",
@@ -354,6 +366,18 @@
"const": "deny-remove-listener",
"markdownDescription": "Denies the remove_listener command without any pre-configured scope."
},
{
"description": "Enables the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "allow-requestPermissions",
"markdownDescription": "Enables the requestPermissions command without any pre-configured scope."
},
{
"description": "Denies the requestPermissions command without any pre-configured scope.",
"type": "string",
"const": "deny-requestPermissions",
"markdownDescription": "Denies the requestPermissions command without any pre-configured scope."
},
{
"description": "Enables the resume command without any pre-configured scope.",
"type": "string",
@@ -366,6 +390,18 @@
"const": "deny-resume",
"markdownDescription": "Denies the resume command without any pre-configured scope."
},
{
"description": "Enables the set_media_session_active command without any pre-configured scope.",
"type": "string",
"const": "allow-set-media-session-active",
"markdownDescription": "Enables the set_media_session_active command without any pre-configured scope."
},
{
"description": "Denies the set_media_session_active command without any pre-configured scope.",
"type": "string",
"const": "deny-set-media-session-active",
"markdownDescription": "Denies the set_media_session_active command without any pre-configured scope."
},
{
"description": "Enables the set_pitch command without any pre-configured scope.",
"type": "string",
@@ -427,10 +463,34 @@
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`",
"description": "Enables the update_media_session_metadata command without any pre-configured scope.",
"type": "string",
"const": "allow-update-media-session-metadata",
"markdownDescription": "Enables the update_media_session_metadata command without any pre-configured scope."
},
{
"description": "Denies the update_media_session_metadata command without any pre-configured scope.",
"type": "string",
"const": "deny-update-media-session-metadata",
"markdownDescription": "Denies the update_media_session_metadata command without any pre-configured scope."
},
{
"description": "Enables the update_media_session_state command without any pre-configured scope.",
"type": "string",
"const": "allow-update-media-session-state",
"markdownDescription": "Enables the update_media_session_state command without any pre-configured scope."
},
{
"description": "Denies the update_media_session_state command without any pre-configured scope.",
"type": "string",
"const": "deny-update-media-session-state",
"markdownDescription": "Denies the update_media_session_state command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-registerListener`\n- `allow-remove-listener`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-registerListener`\n- `allow-remove-listener`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-init`\n- `allow-speak`\n- `allow-stop`\n- `allow-pause`\n- `allow-resume`\n- `allow-set-rate`\n- `allow-set-pitch`\n- `allow-set-voice`\n- `allow-get-all-voices`\n- `allow-set-media-session-active`\n- `allow-update-media-session-state`\n- `allow-update-media-session-metadata`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-registerListener`\n- `allow-remove-listener`"
}
]
}
@@ -51,3 +51,27 @@ pub(crate) async fn set_voice<R: Runtime>(app: AppHandle<R>, payload: SetVoiceAr
pub(crate) async fn get_all_voices<R: Runtime>(app: AppHandle<R>) -> Result<GetVoicesResponse> {
app.native_tts().get_all_voices()
}
#[command]
pub(crate) async fn set_media_session_active<R: Runtime>(
app: AppHandle<R>,
payload: SetMediaSessionActiveRequest,
) -> Result<()> {
app.native_tts().set_media_session_active(payload)
}
#[command]
pub(crate) async fn update_media_session_state<R: Runtime>(
app: AppHandle<R>,
payload: UpdateMediaSessionStateRequest,
) -> Result<()> {
app.native_tts().update_media_session_state(payload)
}
#[command]
pub(crate) async fn update_media_session_metadata<R: Runtime>(
app: AppHandle<R>,
payload: UpdateMediaSessionMetadataRequest,
) -> Result<()> {
app.native_tts().update_media_session_metadata(payload)
}
@@ -41,4 +41,22 @@ impl<R: Runtime> NativeTts<R> {
pub fn get_all_voices(&self) -> crate::Result<GetVoicesResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn set_media_session_active(
&self,
_payload: SetMediaSessionActiveRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn update_media_session_state(
&self,
_payload: UpdateMediaSessionStateRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn update_media_session_metadata(
&self,
_payload: UpdateMediaSessionMetadataRequest,
) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
}
@@ -45,6 +45,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_pitch,
commands::set_voice,
commands::get_all_voices,
commands::set_media_session_active,
commands::update_media_session_state,
commands::update_media_session_metadata,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -87,3 +87,36 @@ impl<R: Runtime> NativeTts<R> {
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn set_media_session_active(
&self,
payload: SetMediaSessionActiveRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("set_media_session_active", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn update_media_session_state(
&self,
payload: UpdateMediaSessionStateRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("update_media_session_state", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeTts<R> {
pub fn update_media_session_metadata(
&self,
payload: UpdateMediaSessionMetadataRequest,
) -> crate::Result<()> {
self.0
.run_mobile_plugin("update_media_session_metadata", payload)
.map_err(Into::into)
}
}
@@ -61,3 +61,26 @@ pub struct SetVoiceArgs {
pub struct GetVoicesResponse {
pub voices: Vec<TTSVoice>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMediaSessionActiveRequest {
pub active: bool,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMediaSessionStateRequest {
pub playing: bool,
pub position: Option<f64>,
pub duration: Option<f64>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMediaSessionMetadataRequest {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub artwork: Option<String>,
}
@@ -6,6 +6,7 @@ import { PiUserCircleCheck } from 'react-icons/pi';
import { TbSunMoon } from 'react-icons/tb';
import { BiMoon, BiSun } from 'react-icons/bi';
import { invoke, PermissionState } from '@tauri-apps/api/core';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { DOWNLOAD_READEST_URL } from '@/services/constants';
import { useAuth } from '@/context/AuthContext';
@@ -29,6 +30,10 @@ interface SettingsMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
}
interface Permissions {
postNotification: PermissionState;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
@@ -47,6 +52,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
settings.autoImportBooksOnOpen,
);
const [isTelemetryEnabled, setIsTelemetryEnabled] = useState(settings.telemetryEnabled);
const [alwaysInForeground, setAlwaysInForeground] = useState(settings.alwaysInForeground);
const iconSize = useResponsiveSize(16);
const showAboutReadest = () => {
@@ -168,6 +174,25 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
setIsDropdownOpen?.(false);
};
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;
}
settings.alwaysInForeground = requestAlwaysInForeground;
setSettings(settings);
saveSettings(envConfig, settings);
setAlwaysInForeground(settings.alwaysInForeground);
};
const avatarUrl = user?.user_metadata?.['picture'] || user?.user_metadata?.['avatar_url'];
const userFullName = user?.user_metadata?.['full_name'];
const userDisplayName = userFullName ? userFullName.split(' ')[0] : null;
@@ -262,6 +287,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ setIsDropdownOpen }) => {
toggled={isScreenWakeLock}
onClick={toggleScreenWakeLock}
/>
{appService?.isAndroidApp && (
<MenuItem
label={_(_('Background Read Aloud'))}
toggled={alwaysInForeground}
onClick={toggleAlwaysInForeground}
/>
)}
<MenuItem label={_('Reload Page')} onClick={handleReloadPage} />
<MenuItem
label={themeModeLabel}
@@ -2,19 +2,22 @@ import clsx from 'clsx';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useThemeStore } from '@/store/themeStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { TTSController, SILENCE_DATA, TTSMark } from '@/services/tts';
import { getMediaSession, TauriMediaSession } from '@/libs/mediaSession';
import { getPopupPosition, Position } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { parseSSMLLang } from '@/utils/ssml';
import { throttle } from '@/utils/throttle';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { CFI } from '@/libs/document';
import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import { fetchImageAsBase64 } from '@/utils/image';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -33,6 +36,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { safeAreaInsets } = useThemeStore();
const { settings } = useSettingsStore();
const { getBookData } = useBookDataStore();
const { hoveredBookKey, getView, getProgress, getViewSettings } = useReaderStore();
const { setViewSettings, setTTSEnabled } = useReaderStore();
@@ -58,6 +62,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const iconRef = useRef<HTMLDivElement>(null);
const unblockerAudioRef = useRef<HTMLAudioElement | null>(null);
const ttsControllerRef = useRef<TTSController | null>(null);
const mediaSessionRef = useRef<TauriMediaSession | MediaSession | null>(null);
const [ttsController, setTtsController] = useState<TTSController | null>(null);
const [ttsClientsInited, setTtsClientsInitialized] = useState(false);
@@ -92,6 +97,43 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
};
const initMediaSession = async () => {
const mediaSession = getMediaSession();
if (!mediaSession) return;
mediaSessionRef.current = mediaSession;
if (mediaSession instanceof TauriMediaSession) {
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey);
if (!bookData || !bookData.book) return;
const { title, author, coverImageUrl } = bookData.book;
const { sectionLabel } = progress || {};
let artworkImage = '/icon.png';
try {
artworkImage = await fetchImageAsBase64(coverImageUrl || '/icon.png');
} catch {
artworkImage = await fetchImageAsBase64('/icon.png');
}
await mediaSession.setActive(true, settings.alwaysInForeground);
mediaSession.updateMetadata({
title: title,
artist: sectionLabel || title,
album: author,
artwork: artworkImage,
});
}
};
const deinitMediaSession = async () => {
if (mediaSessionRef.current && mediaSessionRef.current instanceof TauriMediaSession) {
await mediaSessionRef.current.setActive(false);
}
mediaSessionRef.current = null;
};
useEffect(() => {
return () => {
if (ttsControllerRef.current) {
@@ -121,13 +163,24 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
const progress = getProgress(bookKey);
const { sectionLabel } = progress || {};
const mark = (e as CustomEvent<TTSMark>).detail;
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
if (mediaSessionRef.current) {
const mediaSession = mediaSessionRef.current;
if (mediaSession instanceof TauriMediaSession) {
mediaSession.updateMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: '',
});
} else {
mediaSession.metadata = new MediaMetadata({
title: mark?.text || '',
artist: sectionLabel || title,
album: author,
artwork: [{ src: coverImageUrl || '/icon.png', sizes: '512x512', type: 'image/png' }],
});
}
}
};
@@ -205,7 +258,10 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (appService?.isMobile) {
unblockAudio();
}
await initMediaSession();
setTtsClientsInitialized(false);
setShowIndicator(true);
const ttsController = new TTSController(appService, view);
await ttsController.init();
await ttsController.initViewTTS();
@@ -223,7 +279,6 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
}
setTtsClientsInitialized(true);
setTTSEnabled(bookKey, true);
setShowIndicator(true);
} catch (error) {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for this document'),
@@ -302,6 +357,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
if (appService?.isMobile) {
releaseUnblockAudio();
}
await deinitMediaSession();
setTTSEnabled(bookKey, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -425,32 +481,33 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
};
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => {
const { current: mediaSession } = mediaSessionRef;
if (mediaSession) {
mediaSession.setActionHandler('play', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('pause', () => {
mediaSession.setActionHandler('pause', () => {
handleTogglePlay();
});
navigator.mediaSession.setActionHandler('stop', () => {
mediaSession.setActionHandler('stop', () => {
handlePause();
});
navigator.mediaSession.setActionHandler('seekforward', () => {
mediaSession.setActionHandler('seekforward', () => {
handleForward(true);
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
mediaSession.setActionHandler('seekbackward', () => {
handleBackward(true);
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
mediaSession.setActionHandler('nexttrack', () => {
handleForward();
});
navigator.mediaSession.setActionHandler('previoustrack', () => {
mediaSession.setActionHandler('previoustrack', () => {
handleBackward();
});
}
@@ -263,7 +263,7 @@ export function useTextTranslation(
);
useEffect(() => {
if (viewState?.ttsEnabled && progress && document.hidden) {
if (enabled.current && viewState?.ttsEnabled && progress && document.hidden) {
const { range } = progress;
translateInRange(range);
}
+135
View File
@@ -0,0 +1,135 @@
import { isTauriAppPlatform } from '@/services/environment';
import { getOSPlatform } from '@/utils/misc';
import { invoke } from '@tauri-apps/api/core';
import { addPluginListener, PluginListener, PermissionState } from '@tauri-apps/api/core';
export interface MediaMetadata {
title?: string;
artist?: string;
album?: string;
artwork?: string;
}
export interface PlaybackState {
playing: boolean;
position: number;
duration: number;
}
interface Permissions {
postNotification: PermissionState;
}
export class TauriMediaSession {
private handlers: { [key: string]: (() => void) | ((position: number) => void) } = {};
private eventListenerInited: boolean = false;
private eventListeners: PluginListener[] = [];
private async initializeListeners(keepAppInForeground: boolean) {
if (keepAppInForeground) {
const permission = await invoke<Permissions>('plugin:native-tts|checkPermissions');
if (permission.postNotification.startsWith('prompt')) {
await invoke<Permissions>('plugin:native-tts|requestPermissions', {
permissions: ['postNotification'],
});
}
}
if (this.eventListenerInited) return;
this.eventListenerInited = true;
const playListener = await addPluginListener('native-tts', 'media-session-play', () => {
if (this.handlers['play']) {
(this.handlers['play'] as () => void)();
}
});
this.eventListeners.push(playListener);
const pauseListener = await addPluginListener('native-tts', 'media-session-pause', () => {
if (this.handlers['pause']) {
(this.handlers['pause'] as () => void)();
}
});
this.eventListeners.push(pauseListener);
const nextListener = await addPluginListener('native-tts', 'media-session-next', () => {
if (this.handlers['nexttrack']) {
(this.handlers['nexttrack'] as () => void)();
}
});
this.eventListeners.push(nextListener);
const previousListener = await addPluginListener('native-tts', 'media-session-previous', () => {
if (this.handlers['previoustrack']) {
(this.handlers['previoustrack'] as () => void)();
}
});
this.eventListeners.push(previousListener);
const seekListener = await addPluginListener(
'native-tts',
'media-session-seek',
(event: { payload: { position: number } }) => {
const position = event.payload.position;
if (this.handlers['seekto']) {
(this.handlers['seekto'] as (position: number) => void)(position);
}
},
);
this.eventListeners.push(seekListener);
}
private async cleanupListeners() {
for (const listener of this.eventListeners) {
await listener.unregister();
}
this.eventListeners = [];
this.eventListenerInited = false;
}
async updateMetadata(metadata: MediaMetadata) {
try {
await invoke('plugin:native-tts|update_media_session_metadata', { payload: metadata });
} catch (error) {
console.error('Failed to update media metadata:', error);
}
}
async updatePlaybackState(state: PlaybackState) {
try {
await invoke('plugin:native-tts|update_media_session_state', { payload: state });
} catch (error) {
console.error('Failed to update playback state:', error);
}
}
async setActive(active: boolean, keepAppInForeground: boolean = false) {
try {
if (active) {
await this.initializeListeners(keepAppInForeground);
} else {
await this.cleanupListeners();
}
await invoke('plugin:native-tts|set_media_session_active', { payload: { active } });
} catch (error) {
console.error('Failed to set media session active state:', error);
}
}
setActionHandler(action: string, handler: (() => void) | ((position: number) => void) | null) {
if (handler) {
this.handlers[action] = handler;
} else {
delete this.handlers[action];
}
}
}
export function getMediaSession() {
if ('mediaSession' in navigator) {
return navigator.mediaSession;
} else if (getOSPlatform() === 'android' && isTauriAppPlatform()) {
return new TauriMediaSession();
}
return null;
}
@@ -57,6 +57,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
alwaysOnTop: false,
openBookInNewWindow: true,
alwaysShowStatusBar: false,
alwaysInForeground: false,
autoCheckUpdates: true,
screenWakeLock: false,
openLastBooks: false,
+1
View File
@@ -47,6 +47,7 @@ export interface SystemSettings {
autoCheckUpdates: boolean;
screenWakeLock: boolean;
alwaysShowStatusBar: boolean;
alwaysInForeground: boolean;
openLastBooks: boolean;
lastOpenBooks: string[];
autoImportBooksOnOpen: boolean;
+74
View File
@@ -0,0 +1,74 @@
export async function fetchImageAsBase64(
url: string,
options: {
targetWidth?: number;
format?: 'image/jpeg' | 'image/png' | 'image/webp';
quality?: number;
} = {},
): Promise<string> {
const { targetWidth = 256, format = 'image/jpeg', quality = 0.85 } = options;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const img = new Image();
img.crossOrigin = 'anonymous';
return new Promise((resolve, reject) => {
img.onload = () => {
try {
const aspectRatio = img.height / img.width;
const newWidth = targetWidth;
const newHeight = Math.round(newWidth * aspectRatio);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
canvas.width = newWidth;
canvas.height = newHeight;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, newWidth, newHeight);
const base64 = canvas.toDataURL(format, quality);
resolve(base64);
} catch (error) {
reject(new Error(`Failed to scale image: ${error}`));
}
};
img.onerror = () => reject(new Error('Failed to load image for scaling'));
const objectUrl = URL.createObjectURL(blob);
img.src = objectUrl;
const cleanup = () => URL.revokeObjectURL(objectUrl);
const originalOnload = img.onload;
const originalOnerror = img.onerror;
img.onload = function (ev) {
cleanup();
if (originalOnload) originalOnload.call(this, ev);
};
img.onerror = function (ev) {
cleanup();
if (originalOnerror) originalOnerror.call(this, ev);
};
});
} catch (error) {
console.error('Error fetching and encoding image:', error);
throw error;
}
}