diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index a8423bc9..3b96dd68 100644 --- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -115,11 +115,22 @@ + + + + + + + android:name="com.readest.native_tts.MediaPlaybackService" + android:foregroundServiceType="mediaPlayback" + android:exported="true"> + + + + + Log.i("MediaPlaybackService", "Audio focus changed: $focusChange, $player.isPlaying") + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + player.volume = 1.0f + if (!player.isPlaying) player.play() + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + player.volume = 0.3f + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + if (player.isPlaying) player.pause() + } + } + } + + companion object { + private const val CHANNEL_ID = "media2_playback_channel" + private const val NOTIFICATION_ID = 1002 + private const val MEDIA_ROOT_ID = "media_root_id" + + var pluginEventTrigger: ((String, JSObject) -> Unit)? = null + + var currentTitle: String = "Read Aloud" + var currentArtist: String = "Reading your content" + var currentArtwork: Bitmap? = null + } + + override fun onCreate() { + super.onCreate() + + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + val result = audioManager.requestAudioFocus( + afChangeListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN + ) + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.d("MediaPlaybackService", "Audio focus granted") + } else { + Log.w("MediaPlaybackService", "Failed to gain audio focus") + } + + player = ExoPlayer.Builder(this).build() + + mediaSession = MediaSessionCompat(baseContext, "ReadestMediaSession").apply { + stateBuilder = PlaybackStateCompat.Builder().setActions( + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_STOP or + PlaybackStateCompat.ACTION_SKIP_TO_NEXT or + PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + setPlaybackState(stateBuilder.build()) + setCallback(SessionCallback()) + setSessionToken(sessionToken) + isActive = true + } + + player.addListener(object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + updatePlaybackState() + } + override fun onPlaybackStateChanged(playbackState: Int) { + updatePlaybackState() + } + }) + + val mediaItem = MediaItem.fromUri("asset:///silence.mp3") + player.setMediaItem(mediaItem) + player.repeatMode = Player.REPEAT_MODE_ONE + player.prepare() + player.playWhenReady = true + + showNotification(PlaybackStateCompat.STATE_PLAYING) + } + + private inner class SessionCallback : MediaSessionCompat.Callback() { + override fun onPlay() { + player.play() + pluginEventTrigger?.invoke("media-session-play", JSObject()) + updatePlaybackState() + } + + override fun onPause() { + player.pause() + pluginEventTrigger?.invoke("media-session-pause", JSObject()) + updatePlaybackState() + } + + override fun onSkipToNext() { + player.seekTo(0) + pluginEventTrigger?.invoke("media-session-next", JSObject()) + } + + override fun onSkipToPrevious() { + player.seekTo(0) + pluginEventTrigger?.invoke("media-session-previous", JSObject()) + } + } + + private fun updatePlaybackState() { + val state = if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED + mediaSession?.setPlaybackState( + stateBuilder.setState(state, player.currentPosition, 1f).build() + ) + showNotification(state) + } + + private fun showNotification(playbackState: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "Media Controls", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + startForeground(NOTIFICATION_ID, buildNotification(playbackState)) + } + + private fun buildNotification(playbackState: Int): Notification { + val builder = NotificationCompat.Builder(this, CHANNEL_ID).apply { + setContentTitle(currentTitle) + setContentText(currentArtist) + setLargeIcon(currentArtwork) + setContentIntent(mediaSession!!.controller.sessionActivity) + setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this@MediaPlaybackService, PlaybackStateCompat.ACTION_STOP)) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setSmallIcon(R.drawable.notification_icon) + + setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSession?.sessionToken) + .setShowActionsInCompactView(0) + ) + } + return builder.build() + } + + override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { + return BrowserRoot(MEDIA_ROOT_ID, null) + } + + override fun onLoadChildren(parentId: String, result: Result>) { + result.sendResult(null) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + MediaButtonReceiver.handleIntent(mediaSession, intent) + + if (intent?.action == "UPDATE_METADATA") { + currentTitle = intent.getStringExtra("title") ?: currentTitle + currentArtist = intent.getStringExtra("artist") ?: currentArtist + val newArtwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra("artwork", Bitmap::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra("artwork") + } + if (newArtwork != null) { + currentArtwork = newArtwork + } + + val metadataBuilder = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentTitle) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentArtist) + .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, currentArtwork) + + mediaSession?.setMetadata(metadataBuilder.build()) + + showNotification(if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED) + } else if (intent?.action == "UPDATE_PLAYBACK_STATE") { + val isPlaying = intent.getBooleanExtra("playing", false) + val position = intent.getLongExtra("position", 0L) // in milliseconds + val duration = intent.getLongExtra("duration", 0L) // in milliseconds + + if (isPlaying && !player.isPlaying) { + player.play() + } else if (!isPlaying && player.isPlaying) { + player.pause() + } + player.seekTo(position) + + val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED + mediaSession?.setPlaybackState( + stateBuilder.setState(state, position, 1f).build() + ) + showNotification(state) + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + super.onDestroy() + player.release() + mediaSession?.release() + } +} \ No newline at end of file diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt index 20de26e8..dd0a8172 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/java/NativeTTSPlugin.kt @@ -11,13 +11,7 @@ import android.speech.tts.Voice import android.util.Log import android.graphics.Bitmap import android.graphics.BitmapFactory -import androidx.media3.common.MediaMetadata -import androidx.media3.common.Player -import androidx.media3.common.MediaItem -import androidx.media3.common.util.UnstableApi -import androidx.media3.common.ForwardingPlayer -import androidx.media3.session.MediaSession -import androidx.media3.exoplayer.ExoPlayer +import androidx.core.content.ContextCompat import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.Permission @@ -92,8 +86,8 @@ class UpdateMediaSessionMetadataArgs { @InvokeArg class UpdateMediaSessionStateArgs { var playing: Boolean? = null - var position: Double? = null - var duration: Double? = null + var position: Int? = null // in milliseconds + var duration: Int? = null // in milliseconds } @InvokeArg @@ -106,78 +100,6 @@ class SetMediaSessionActiveArgs { var foregroundServiceText: String? = null } -class MediaForegroundService : Service() { - companion object { - const val NOTIFICATION_ID = 1001 - const val CHANNEL_ID = "tts_media_playback_channel" - const val ACTION_START = "START_FOREGROUND_SERVICE" - const val ACTION_STOP = "STOP_FOREGROUND_SERVICE" - - var pluginInstance: NativeTTSPlugin? = null - } - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_START -> { - startForegroundService() - } - ACTION_STOP -> { - stopForegroundService() - } - } - return START_STICKY // Restart if killed - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - NativeTTSPlugin.NOTIFICATION_TITLE, - NotificationManager.IMPORTANCE_MIN - ).apply { - description = NativeTTSPlugin.NOTIFICATION_TEXT - setShowBadge(false) - setSound(null, null) - } - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - } - } - - private fun startForegroundService() { - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(NativeTTSPlugin.FOREGROUND_SERVICE_TITLE) - .setContentText(NativeTTSPlugin.FOREGROUND_SERVICE_TEXT) - .setSmallIcon(android.R.drawable.ic_media_play) - .setOngoing(true) - .setSilent(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .build() - - startForeground(NOTIFICATION_ID, notification) - Log.d("MediaForegroundService", "Foreground service started") - } - - private fun stopForegroundService() { - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - Log.d("MediaForegroundService", "Foreground service stopped") - } - - override fun onDestroy() { - super.onDestroy() - Log.d("MediaForegroundService", "Service destroyed") - } -} - @TauriPlugin( permissions = [ Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "postNotification") @@ -203,18 +125,8 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { private val eventChannels = ConcurrentHashMap>() private val speakingJobs = ConcurrentHashMap() - - private var player: Player? = null - private var mediaSession: MediaSession? = null - private var isMediaSessionActive = false - private var isForegroundServiceRunning = false - private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - init { - MediaForegroundService.pluginInstance = this - } - @Command fun init(invoke: Invoke) { coroutineScope.launch { @@ -509,95 +421,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - @UnstableApi - private class WebViewPlayer(player: Player) : ForwardingPlayer(player) { - var eventTrigger: ((String, JSObject) -> Unit)? = null - - override fun play() { - eventTrigger?.invoke("media-session-play", JSObject()) - if (!this.playWhenReady) { - this.playWhenReady = true - } else { - this.playWhenReady = false - } - } - override fun pause() { - eventTrigger?.invoke("media-session-pause", JSObject()) - if (!this.playWhenReady) { - this.playWhenReady = true - } else { - this.playWhenReady = false - } - } - override fun seekToNext() { - eventTrigger?.invoke("media-session-next", JSObject()) - } - override fun seekToPrevious() { - eventTrigger?.invoke("media-session-previous", JSObject()) - } - } - - private fun initializeMediaSession() { - var basePlayer = ExoPlayer.Builder(activity).build() - val webViewPlayer = WebViewPlayer(basePlayer) - webViewPlayer.eventTrigger = { event, data -> trigger(event, data) } - player = webViewPlayer - - mediaSession = MediaSession.Builder(activity, player!!) - .setCallback(object : MediaSession.Callback { - override fun onConnect( - session: MediaSession, - controller: MediaSession.ControllerInfo - ): MediaSession.ConnectionResult { - val builder = MediaSession.ConnectionResult.AcceptedResultBuilder(session) - builder.setAvailablePlayerCommands( - Player.Commands.Builder() - .add(Player.COMMAND_PLAY_PAUSE) - .add(Player.COMMAND_SEEK_TO_NEXT) - .add(Player.COMMAND_SEEK_TO_PREVIOUS) - .build() - ) - return builder.build() - } - }) - .build() - } - - private fun deinitializeMediaSession() { - mediaSession?.release() - mediaSession = null - player?.release() - player = null - } - - private fun startForegroundService() { - try { - val intent = Intent(activity, MediaForegroundService::class.java).apply { - action = MediaForegroundService.ACTION_START - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - activity.startForegroundService(intent) - } else { - activity.startService(intent) - } - Log.d(TAG, "Foreground service started") - } catch (e: Exception) { - Log.e(TAG, "Failed to start foreground service", e) - } - } - - private fun stopForegroundService() { - try { - val intent = Intent(activity, MediaForegroundService::class.java).apply { - action = MediaForegroundService.ACTION_STOP - } - activity.stopService(intent) - Log.d(TAG, "Foreground service stopped") - } catch (e: Exception) { - Log.e(TAG, "Failed to stop foreground service", e) - } - } - private suspend fun loadArtworkFromUrl(urlString: String): Bitmap? { return withContext(Dispatchers.IO) { try { @@ -624,50 +447,26 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { } } - private var currentArtworkByteArray: ByteArray? = null - @Command fun update_media_session_metadata(invoke: Invoke) { val args = invoke.parseArgs(UpdateMediaSessionMetadataArgs::class.java) val title = args.title ?: "" val artist = args.artist ?: "" val album = args.album ?: "" - val artworkUrl = args.artwork ?: "" - + coroutineScope.launch { try { - val metadataBuilder = MediaMetadata.Builder() - .setTitle(title) - .setArtist(artist) - .setAlbumTitle(album) - .apply { - if (artworkUrl.isNotEmpty()) { - loadArtworkFromUrl(artworkUrl)?.let { bitmap -> - java.io.ByteArrayOutputStream().use { stream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - currentArtworkByteArray = stream.toByteArray() - } - } - } - currentArtworkByteArray?.let { - setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) - } - } - - val mediaItem: MediaItem = MediaItem.Builder() - .setMediaId("silent_media") - .setUri("data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQQAAAAAAA==") - .setMediaMetadata(metadataBuilder.build()) - .build() - - player?.setMediaItems(listOf(mediaItem, mediaItem)) - player?.prepare() - player?.playWhenReady = true - player?.setRepeatMode(Player.REPEAT_MODE_ALL) - + val artworkBitmap = args.artwork?.let { loadArtworkFromUrl(it) } + val intent = Intent(activity, MediaPlaybackService::class.java).apply { + action = "UPDATE_METADATA" + putExtra("title", title) + putExtra("artist", artist) + putExtra("album", album) + putExtra("artwork", artworkBitmap) + } + activity.startService(intent) invoke.resolve() } catch (e: Exception) { - Log.e("NativeBridgePlugin", "Failed to update metadata: ${e.message}") invoke.reject("Failed to update metadata: ${e.message}") } } @@ -677,15 +476,17 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { fun update_media_session_state(invoke: Invoke) { var args = invoke.parseArgs(UpdateMediaSessionStateArgs::class.java) val isPlaying = args.playing ?: false - val position = args.position ?: 0.0 - + val position = args.position ?: 0 + val duration = args.duration ?: 0 + try { - player?.let { player -> - if (position > 0) { - player.seekTo((position * 1000).toLong()) - } - player.playWhenReady = isPlaying + val intent = Intent(activity, MediaPlaybackService::class.java).apply { + action = "UPDATE_PLAYBACK_STATE" + putExtra("playing", isPlaying) + putExtra("position", position) + putExtra("duration", duration) } + activity.startService(intent) invoke.resolve() } catch (e: Exception) { invoke.reject("Failed to update playback state: ${e.message}") @@ -696,7 +497,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { fun set_media_session_active(invoke: Invoke) { var args = invoke.parseArgs(SetMediaSessionActiveArgs::class.java) val active = args.active ?: true - val keepAppInForeground = args.keepAppInForeground ?: false args.notificationTitle?.let { NOTIFICATION_TITLE = it } args.notificationText?.let { NOTIFICATION_TEXT = it } @@ -704,41 +504,26 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { args.foregroundServiceText?.let { FOREGROUND_SERVICE_TEXT = it } try { - if (active && !isMediaSessionActive) { - initializeMediaSession() - if (keepAppInForeground) { - startForegroundService() - isForegroundServiceRunning = true - } - isMediaSessionActive = true - Log.d(TAG, "Media session activated with foreground service") - } else if (!active && isMediaSessionActive) { - deinitializeMediaSession() - if (isForegroundServiceRunning) { - stopForegroundService() - isForegroundServiceRunning = false - } - isMediaSessionActive = false - Log.d(TAG, "Media session deactivated and foreground service stopped") + val intent = Intent(activity, MediaPlaybackService::class.java) + if (active) { + MediaPlaybackService.pluginEventTrigger = { event, data -> trigger(event, data) } + MediaPlaybackService.currentTitle = FOREGROUND_SERVICE_TITLE + MediaPlaybackService.currentArtist = FOREGROUND_SERVICE_TEXT + ContextCompat.startForegroundService(activity, intent) + } else { + activity.stopService(intent) + MediaPlaybackService.pluginEventTrigger = null } invoke.resolve() } catch (e: Exception) { - Log.e(TAG, "Failed to set media session active state: ${e.message}") invoke.reject("Failed to set media session active state: ${e.message}") } } fun destroy() { try { - if (isMediaSessionActive) { - if (isForegroundServiceRunning) { - stopForegroundService() - isForegroundServiceRunning = false - } - isMediaSessionActive = false - } - - deinitializeMediaSession() + val intent = Intent(activity, MediaPlaybackService::class.java) + activity.stopService(intent) coroutineScope.cancel() textToSpeech?.shutdown() @@ -747,8 +532,6 @@ class NativeTTSPlugin(private val activity: Activity) : Plugin(activity) { speakingJobs.values.forEach { it.cancel() } speakingJobs.clear() - MediaForegroundService.pluginInstance = null - Log.d(TAG, "Plugin destroyed successfully") } catch (e: Exception) { Log.e(TAG, "Error during plugin destruction", e) diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/res/drawable/notification_icon.png b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/res/drawable/notification_icon.png new file mode 100644 index 00000000..3b7e495a Binary files /dev/null and b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-tts/android/src/main/res/drawable/notification_icon.png differ diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index 6edf2f1c..b7abb36e 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -324,6 +324,15 @@ const TTSControl: React.FC = ({ bookKey, gridInsets }) => { await ttsController.start(); } } + + if (mediaSessionRef.current) { + const mediaSession = mediaSessionRef.current; + if (mediaSession instanceof TauriMediaSession) { + await mediaSession.updatePlaybackState({ playing: !isPlaying }); + } else { + mediaSession.playbackState = isPlaying ? 'paused' : 'playing'; + } + } }, [isPlaying, isPaused]); const handleBackward = useCallback(async (byMark = false) => { diff --git a/apps/readest-app/src/libs/mediaSession.ts b/apps/readest-app/src/libs/mediaSession.ts index 0df1860c..f11ff142 100644 --- a/apps/readest-app/src/libs/mediaSession.ts +++ b/apps/readest-app/src/libs/mediaSession.ts @@ -12,8 +12,8 @@ export interface MediaMetadata { export interface PlaybackState { playing: boolean; - position: number; - duration: number; + position?: number; // in milliseconds + duration?: number; // in milliseconds } export interface MediaSessionState { diff --git a/apps/readest-app/src/styles/themes.ts b/apps/readest-app/src/styles/themes.ts index 44df104f..794d38a1 100644 --- a/apps/readest-app/src/styles/themes.ts +++ b/apps/readest-app/src/styles/themes.ts @@ -230,7 +230,6 @@ export const applyCustomTheme = ( const lightThemeName = customTheme ? `${customTheme.name}-light` : `${themeName}-light`; const darkThemeName = customTheme ? `${customTheme.name}-dark` : `${themeName}-dark`; - console.log('themeName', themeName); const lightPalette = customTheme ? generateLightPalette(customTheme.colors.light) : (themes.find((t) => t.name === themeName) || themes[0]!).colors.light;