cc0428752b
Replace expo-notifications based TTS controls with a native NativeTTSMediaControl TurboModule using Android's MediaSessionCompat and MediaStyle notification. This provides proper media integration with lock screen controls, cover art display, and seekbar. - Add NativeTTSMediaControl Kotlin TurboModule with MediaSession - Support Prev, Rewind, Play/Pause, Next notification buttons - Show novel cover as notification album art - Add seekbar with seek-to-element support - Replace MMKV polling with NativeEventEmitter for instant response - Add rewind and seekTo methods to TTS in core.js - Clean up expo-notifications TTS setup from App.tsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
356 lines
12 KiB
Kotlin
356 lines
12 KiB
Kotlin
package com.rajarsheechatterjee.NativeTTSMediaControl
|
|
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.graphics.Bitmap
|
|
import android.graphics.BitmapFactory
|
|
import android.os.Build
|
|
import android.support.v4.media.MediaMetadataCompat
|
|
import android.support.v4.media.session.MediaSessionCompat
|
|
import android.support.v4.media.session.PlaybackStateCompat
|
|
import androidx.core.app.NotificationCompat
|
|
import androidx.media.app.NotificationCompat.MediaStyle
|
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
import com.lnreader.spec.NativeTTSMediaControlSpec
|
|
import java.io.File
|
|
import java.net.URL
|
|
|
|
class NativeTTSMediaControl(private val appContext: ReactApplicationContext) :
|
|
NativeTTSMediaControlSpec(appContext) {
|
|
|
|
companion object {
|
|
private const val CHANNEL_ID = "tts-media-controls"
|
|
private const val NOTIFICATION_ID = 1001
|
|
private const val ACTION_PLAY = "com.lnreader.TTS_PLAY"
|
|
private const val ACTION_PAUSE = "com.lnreader.TTS_PAUSE"
|
|
private const val ACTION_STOP = "com.lnreader.TTS_STOP"
|
|
private const val ACTION_PREV = "com.lnreader.TTS_PREV"
|
|
private const val ACTION_NEXT = "com.lnreader.TTS_NEXT"
|
|
private const val ACTION_REWIND = "com.lnreader.TTS_REWIND"
|
|
}
|
|
|
|
private var mediaSession: MediaSessionCompat? = null
|
|
private var isPlaying = false
|
|
private var listenerCount = 0
|
|
private var coverBitmap: Bitmap? = null
|
|
private var currentCoverUri: String? = null
|
|
private var currentTitle: String? = null
|
|
private var currentSubtitle: String? = null
|
|
private var receiverRegistered = false
|
|
private var currentPosition: Long = 0L
|
|
private var totalDuration: Long = 0L
|
|
|
|
private val mediaReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context, intent: Intent) {
|
|
when (intent.action) {
|
|
ACTION_PLAY -> {
|
|
isPlaying = true
|
|
sendEvent("TTSPlay")
|
|
updateNotification()
|
|
}
|
|
ACTION_PAUSE -> {
|
|
isPlaying = false
|
|
sendEvent("TTSPause")
|
|
updateNotification()
|
|
}
|
|
ACTION_STOP -> sendEvent("TTSStop")
|
|
ACTION_PREV -> sendEvent("TTSPrev")
|
|
ACTION_NEXT -> sendEvent("TTSNext")
|
|
ACTION_REWIND -> sendEvent("TTSRewind")
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun sendEvent(eventName: String) {
|
|
if (listenerCount > 0) {
|
|
appContext.getJSModule(
|
|
DeviceEventManagerModule.RCTDeviceEventEmitter::class.java
|
|
).emit(eventName, null)
|
|
}
|
|
}
|
|
|
|
private fun sendSeekEvent(elementIndex: Long) {
|
|
if (listenerCount > 0) {
|
|
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
|
putInt("position", elementIndex.toInt())
|
|
}
|
|
appContext.getJSModule(
|
|
DeviceEventManagerModule.RCTDeviceEventEmitter::class.java
|
|
).emit("TTSSeekTo", params)
|
|
}
|
|
}
|
|
|
|
private fun ensureChannel() {
|
|
val manager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
"TTS Media Controls",
|
|
NotificationManager.IMPORTANCE_LOW
|
|
).apply {
|
|
description = "Text-to-speech playback controls"
|
|
setShowBadge(false)
|
|
}
|
|
manager.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
private fun ensureMediaSession() {
|
|
if (mediaSession == null) {
|
|
mediaSession = MediaSessionCompat(appContext, "LNReaderTTS").apply {
|
|
setCallback(object : MediaSessionCompat.Callback() {
|
|
override fun onPlay() {
|
|
isPlaying = true
|
|
sendEvent("TTSPlay")
|
|
updateNotification()
|
|
}
|
|
|
|
override fun onPause() {
|
|
isPlaying = false
|
|
sendEvent("TTSPause")
|
|
updateNotification()
|
|
}
|
|
|
|
override fun onStop() {
|
|
sendEvent("TTSStop")
|
|
}
|
|
|
|
override fun onSkipToPrevious() {
|
|
sendEvent("TTSPrev")
|
|
}
|
|
|
|
override fun onSkipToNext() {
|
|
sendEvent("TTSNext")
|
|
}
|
|
|
|
override fun onSeekTo(pos: Long) {
|
|
// pos is in our scaled ms domain: elementIndex * 1000
|
|
val elementIndex = pos / 1000L
|
|
currentPosition = elementIndex
|
|
updateNotification()
|
|
sendSeekEvent(elementIndex)
|
|
}
|
|
})
|
|
isActive = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun registerReceiver() {
|
|
if (!receiverRegistered) {
|
|
val filter = IntentFilter().apply {
|
|
addAction(ACTION_PLAY)
|
|
addAction(ACTION_PAUSE)
|
|
addAction(ACTION_STOP)
|
|
addAction(ACTION_PREV)
|
|
addAction(ACTION_NEXT)
|
|
addAction(ACTION_REWIND)
|
|
}
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
appContext.registerReceiver(mediaReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
|
} else {
|
|
appContext.registerReceiver(mediaReceiver, filter)
|
|
}
|
|
receiverRegistered = true
|
|
}
|
|
}
|
|
|
|
private fun buildPendingIntent(action: String): PendingIntent {
|
|
val intent = Intent(action).setPackage(appContext.packageName)
|
|
return PendingIntent.getBroadcast(
|
|
appContext,
|
|
action.hashCode(),
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
}
|
|
|
|
private fun getContentIntent(): PendingIntent {
|
|
val intent = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
|
?: Intent()
|
|
return PendingIntent.getActivity(
|
|
appContext,
|
|
0,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
}
|
|
|
|
private fun updateNotification() {
|
|
val session = mediaSession ?: return
|
|
|
|
val stateBuilder = PlaybackStateCompat.Builder()
|
|
.setActions(
|
|
PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
|
PlaybackStateCompat.ACTION_STOP or
|
|
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
|
|
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
|
|
PlaybackStateCompat.ACTION_SEEK_TO
|
|
)
|
|
.setState(
|
|
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
|
currentPosition * 1000L,
|
|
0f
|
|
)
|
|
session.setPlaybackState(stateBuilder.build())
|
|
|
|
val metadataBuilder = MediaMetadataCompat.Builder()
|
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentSubtitle ?: "")
|
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentTitle ?: "")
|
|
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDuration * 1000L)
|
|
coverBitmap?.let {
|
|
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
|
|
}
|
|
session.setMetadata(metadataBuilder.build())
|
|
|
|
val playPauseAction = if (isPlaying) {
|
|
NotificationCompat.Action.Builder(
|
|
android.R.drawable.ic_media_pause,
|
|
"Pause",
|
|
buildPendingIntent(ACTION_PAUSE)
|
|
).build()
|
|
} else {
|
|
NotificationCompat.Action.Builder(
|
|
android.R.drawable.ic_media_play,
|
|
"Play",
|
|
buildPendingIntent(ACTION_PLAY)
|
|
).build()
|
|
}
|
|
|
|
val prevAction = NotificationCompat.Action.Builder(
|
|
android.R.drawable.ic_media_previous,
|
|
"Previous",
|
|
buildPendingIntent(ACTION_PREV)
|
|
).build()
|
|
|
|
val rewindAction = NotificationCompat.Action.Builder(
|
|
android.R.drawable.ic_media_rew,
|
|
"Replay",
|
|
buildPendingIntent(ACTION_REWIND)
|
|
).build()
|
|
|
|
val nextAction = NotificationCompat.Action.Builder(
|
|
android.R.drawable.ic_media_next,
|
|
"Next",
|
|
buildPendingIntent(ACTION_NEXT)
|
|
).build()
|
|
|
|
val notification = NotificationCompat.Builder(appContext, CHANNEL_ID)
|
|
.setContentTitle(currentSubtitle)
|
|
.setContentText(currentTitle)
|
|
.setSmallIcon(appContext.applicationInfo.icon)
|
|
.setLargeIcon(coverBitmap)
|
|
.setContentIntent(getContentIntent())
|
|
.setDeleteIntent(buildPendingIntent(ACTION_STOP))
|
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
.setOngoing(isPlaying)
|
|
.addAction(prevAction)
|
|
.addAction(rewindAction)
|
|
.addAction(playPauseAction)
|
|
.addAction(nextAction)
|
|
.setStyle(
|
|
MediaStyle()
|
|
.setMediaSession(session.sessionToken)
|
|
.setShowActionsInCompactView(1, 2, 3)
|
|
)
|
|
.build()
|
|
|
|
val manager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
manager.notify(NOTIFICATION_ID, notification)
|
|
}
|
|
|
|
private fun loadCoverBitmap(coverUri: String) {
|
|
if (coverUri == currentCoverUri && coverBitmap != null) return
|
|
currentCoverUri = coverUri
|
|
|
|
if (coverUri.isBlank()) {
|
|
coverBitmap = null
|
|
return
|
|
}
|
|
|
|
if (coverUri.startsWith("file://")) {
|
|
val path = coverUri.removePrefix("file://").split("?")[0]
|
|
val file = File(path)
|
|
if (file.exists()) {
|
|
coverBitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
}
|
|
} else if (coverUri.startsWith("http")) {
|
|
coverBitmap = null
|
|
Thread {
|
|
try {
|
|
val stream = URL(coverUri).openStream()
|
|
val bitmap = BitmapFactory.decodeStream(stream)
|
|
stream.close()
|
|
coverBitmap = bitmap
|
|
updateNotification()
|
|
} catch (_: Exception) {
|
|
// Silently fail — notification will show without cover
|
|
}
|
|
}.start()
|
|
}
|
|
}
|
|
|
|
override fun showMediaNotification(
|
|
title: String,
|
|
subtitle: String,
|
|
coverUri: String,
|
|
isPlaying: Boolean
|
|
) {
|
|
this.isPlaying = isPlaying
|
|
this.currentTitle = title
|
|
this.currentSubtitle = subtitle
|
|
|
|
ensureChannel()
|
|
ensureMediaSession()
|
|
registerReceiver()
|
|
loadCoverBitmap(coverUri)
|
|
updateNotification()
|
|
}
|
|
|
|
override fun updatePlaybackState(isPlaying: Boolean) {
|
|
this.isPlaying = isPlaying
|
|
updateNotification()
|
|
}
|
|
|
|
override fun updateProgress(current: Double, total: Double) {
|
|
currentPosition = current.toLong()
|
|
totalDuration = total.toLong()
|
|
updateNotification()
|
|
}
|
|
|
|
override fun dismiss() {
|
|
mediaSession?.isActive = false
|
|
mediaSession?.release()
|
|
mediaSession = null
|
|
|
|
val manager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
manager.cancel(NOTIFICATION_ID)
|
|
|
|
if (receiverRegistered) {
|
|
try {
|
|
appContext.unregisterReceiver(mediaReceiver)
|
|
} catch (_: Exception) {}
|
|
receiverRegistered = false
|
|
}
|
|
|
|
coverBitmap = null
|
|
currentCoverUri = null
|
|
currentPosition = 0L
|
|
totalDuration = 0L
|
|
}
|
|
|
|
override fun addListener(eventName: String?) {
|
|
listenerCount++
|
|
}
|
|
|
|
override fun removeListeners(count: Double) {
|
|
listenerCount = (listenerCount - count.toInt()).coerceAtLeast(0)
|
|
}
|
|
}
|