feat: Replace TTS Notifications with Android MediaSession Controls (Closes #1711)
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>
This commit is contained in:
@@ -30,38 +30,6 @@ Notifications.setNotificationHandler({
|
||||
},
|
||||
});
|
||||
|
||||
Notifications.setNotificationCategoryAsync('TTS_CONTROLS', [
|
||||
{
|
||||
identifier: 'TTS_PLAY_PAUSE',
|
||||
buttonTitle: '▶️ Play',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'TTS_STOP',
|
||||
buttonTitle: '⏹️ Stop',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'TTS_NEXT',
|
||||
buttonTitle: '⏭️ Next',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Notifications.setNotificationChannelAsync('tts-controls', {
|
||||
name: 'TTS Controls',
|
||||
description: 'Text-to-Speech playback controls',
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
vibrationPattern: [],
|
||||
enableLights: false,
|
||||
enableVibrate: false,
|
||||
});
|
||||
|
||||
const App = () => {
|
||||
const state = useInitDatabase();
|
||||
@@ -72,22 +40,6 @@ const App = () => {
|
||||
}
|
||||
}, [state.success, state.error]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||
response => {
|
||||
const actionIdentifier = response.actionIdentifier;
|
||||
if (actionIdentifier.startsWith('TTS_')) {
|
||||
const { setTTSAction } = require('@utils/ttsNotification');
|
||||
setTTSAction(actionIdentifier);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state.error) {
|
||||
return <ErrorFallback error={state.error} resetError={() => null} />;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ dependencies {
|
||||
// The version of react-native is set by the React Native Gradle Plugin
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation 'androidx.core:core-ktx:1.15.0'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
|
||||
@@ -285,6 +285,7 @@ window.tts = new (function () {
|
||||
|
||||
this.start = element => {
|
||||
this.stop();
|
||||
this.started = true;
|
||||
const startElement = element ?? reader.chapterElement;
|
||||
this.currentElement = startElement;
|
||||
|
||||
@@ -347,10 +348,29 @@ window.tts = new (function () {
|
||||
|
||||
this.pause = () => {
|
||||
this.reading = false;
|
||||
reader.post({ type: 'stop-speak' });
|
||||
reader.post({ type: 'pause-speak' });
|
||||
reader.post({ type: 'tts-state', data: { isReading: false } });
|
||||
};
|
||||
|
||||
this.rewind = () => {
|
||||
if (!this.started || !this.currentElement) return;
|
||||
reader.post({ type: 'pause-speak' });
|
||||
this.reading = true;
|
||||
this.speak();
|
||||
};
|
||||
|
||||
this.seekTo = index => {
|
||||
if (!this.started || !this.allReadableElements.length) return;
|
||||
const targetIndex = Math.max(0, Math.min(index, this.totalElements - 1));
|
||||
reader.post({ type: 'pause-speak' });
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
this.elementsRead = targetIndex;
|
||||
this.currentElement = this.allReadableElements[targetIndex];
|
||||
this.reading = true;
|
||||
this.elementsRead++;
|
||||
this.speak();
|
||||
};
|
||||
|
||||
this.stop = () => {
|
||||
reader.post({ type: 'stop-speak' });
|
||||
this.currentElement?.classList?.remove('highlight');
|
||||
@@ -436,6 +456,7 @@ window.tts = new (function () {
|
||||
type: 'speak',
|
||||
data: text,
|
||||
index: this.elementsRead - 1,
|
||||
total: this.totalElements,
|
||||
});
|
||||
reader.post({ type: 'tts-state', data: { isReading: true } });
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.soloader.SoLoader
|
||||
import com.rajarsheechatterjee.NativeFile.NativePackage
|
||||
import com.rajarsheechatterjee.NativeVolumeButtonListener.NativeVolumeButtonListenerPackage
|
||||
import com.rajarsheechatterjee.NativeTTSMediaControl.NativeTTSMediaControlPackage
|
||||
import com.rajarsheechatterjee.NativeZipArchive.NativeZipArchivePackage
|
||||
import expo.modules.ApplicationLifecycleDispatcher
|
||||
|
||||
@@ -24,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
add(NativePackage())
|
||||
add(NativeTTSMediaControlPackage())
|
||||
add(NativeVolumeButtonListenerPackage())
|
||||
add(NativeZipArchivePackage())
|
||||
}
|
||||
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.rajarsheechatterjee.NativeTTSMediaControl
|
||||
|
||||
import com.facebook.react.BaseReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.module.model.ReactModuleInfo
|
||||
import com.facebook.react.module.model.ReactModuleInfoProvider
|
||||
import com.lnreader.spec.NativeTTSMediaControlSpec
|
||||
|
||||
class NativeTTSMediaControlPackage : BaseReactPackage() {
|
||||
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
||||
if (name == NativeTTSMediaControlSpec.NAME) {
|
||||
NativeTTSMediaControl(reactContext)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
||||
mapOf(
|
||||
NativeTTSMediaControlSpec.NAME to ReactModuleInfo(
|
||||
NativeTTSMediaControlSpec.NAME,
|
||||
NativeTTSMediaControlSpec.NAME,
|
||||
canOverrideExistingModule = false,
|
||||
needsEagerInit = false,
|
||||
isCxxModule = false,
|
||||
isTurboModule = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TurboModule, TurboModuleRegistry } from 'react-native';
|
||||
|
||||
export interface Spec extends TurboModule {
|
||||
showMediaNotification(
|
||||
title: string,
|
||||
subtitle: string,
|
||||
coverUri: string,
|
||||
isPlaying: boolean,
|
||||
): void;
|
||||
updatePlaybackState(isPlaying: boolean): void;
|
||||
updateProgress(current: number, total: number): void;
|
||||
dismiss(): void;
|
||||
addListener: (eventName: string) => void;
|
||||
removeListeners: (count: number) => void;
|
||||
}
|
||||
|
||||
export default TurboModuleRegistry.getEnforcing<Spec>('NativeTTSMediaControl');
|
||||
@@ -28,9 +28,10 @@ import { useChapterContext } from '../ChapterContext';
|
||||
import {
|
||||
showTTSNotification,
|
||||
updateTTSNotification,
|
||||
updateTTSPlaybackState,
|
||||
updateTTSProgress,
|
||||
dismissTTSNotification,
|
||||
getTTSAction,
|
||||
clearTTSAction,
|
||||
ttsMediaEmitter,
|
||||
} from '@utils/ttsNotification';
|
||||
|
||||
type WebViewPostEvent = {
|
||||
@@ -38,6 +39,7 @@ type WebViewPostEvent = {
|
||||
data?: { [key: string]: unknown };
|
||||
autoStartTTS?: boolean;
|
||||
index?: number;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type WebViewReaderProps = {
|
||||
@@ -114,42 +116,57 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
}, [readerSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkNotificationActions = setInterval(() => {
|
||||
const action = getTTSAction();
|
||||
if (action) {
|
||||
clearTTSAction();
|
||||
switch (action) {
|
||||
case 'TTS_PLAY_PAUSE':
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts) {
|
||||
if (tts.reading) {
|
||||
tts.pause();
|
||||
} else {
|
||||
tts.resume();
|
||||
}
|
||||
}
|
||||
`);
|
||||
break;
|
||||
case 'TTS_STOP':
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts) {
|
||||
tts.stop();
|
||||
}
|
||||
`);
|
||||
break;
|
||||
case 'TTS_NEXT':
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && window.reader && window.reader.nextChapter) {
|
||||
window.reader.post({ type: 'next', autoStartTTS: true });
|
||||
}
|
||||
`);
|
||||
break;
|
||||
const playListener = ttsMediaEmitter.addListener('TTSPlay', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && !tts.reading) { tts.resume(); }
|
||||
`);
|
||||
});
|
||||
const pauseListener = ttsMediaEmitter.addListener('TTSPause', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && tts.reading) { tts.pause(); }
|
||||
`);
|
||||
});
|
||||
const stopListener = ttsMediaEmitter.addListener('TTSStop', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts) { tts.stop(); }
|
||||
`);
|
||||
});
|
||||
const rewindListener = ttsMediaEmitter.addListener('TTSRewind', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && tts.started) { tts.rewind(); }
|
||||
`);
|
||||
});
|
||||
const prevListener = ttsMediaEmitter.addListener('TTSPrev', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && window.reader && window.reader.prevChapter) {
|
||||
window.reader.post({ type: 'prev', autoStartTTS: true });
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
`);
|
||||
});
|
||||
const nextListener = ttsMediaEmitter.addListener('TTSNext', () => {
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && window.reader && window.reader.nextChapter) {
|
||||
window.reader.post({ type: 'next', autoStartTTS: true });
|
||||
}
|
||||
`);
|
||||
});
|
||||
const seekToListener = ttsMediaEmitter.addListener(
|
||||
'TTSSeekTo',
|
||||
(event: { position: number }) => {
|
||||
const position = event.position;
|
||||
webViewRef.current?.injectJavaScript(`
|
||||
if (window.tts && tts.started) { tts.seekTo(${position}); }
|
||||
`);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
clearInterval(checkNotificationActions);
|
||||
playListener.remove();
|
||||
pauseListener.remove();
|
||||
stopListener.remove();
|
||||
rewindListener.remove();
|
||||
prevListener.remove();
|
||||
nextListener.remove();
|
||||
seekToListener.remove();
|
||||
};
|
||||
}, [webViewRef]);
|
||||
|
||||
@@ -158,10 +175,11 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
updateTTSNotification({
|
||||
novelName: novel?.name || 'Unknown',
|
||||
chapterName: chapter.name,
|
||||
coverUri: novel?.cover || '',
|
||||
isPlaying: isTTSReadingRef.current,
|
||||
});
|
||||
}
|
||||
}, [novel?.name, chapter.name]);
|
||||
}, [novel?.name, novel?.cover, chapter.name]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -360,6 +378,9 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
navigateChapter('NEXT');
|
||||
break;
|
||||
case 'prev':
|
||||
if (event.autoStartTTS) {
|
||||
autoStartTTSRef.current = true;
|
||||
}
|
||||
navigateChapter('PREV');
|
||||
break;
|
||||
case 'save':
|
||||
@@ -377,37 +398,47 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({ onPress }) => {
|
||||
showTTSNotification({
|
||||
novelName: novel?.name || 'Unknown',
|
||||
chapterName: chapter.name,
|
||||
coverUri: novel?.cover || '',
|
||||
isPlaying: true,
|
||||
});
|
||||
} else {
|
||||
updateTTSNotification({
|
||||
novelName: novel?.name || 'Unknown',
|
||||
chapterName: chapter.name,
|
||||
coverUri: novel?.cover || '',
|
||||
isPlaying: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof event.index === 'number' &&
|
||||
typeof event.total === 'number' &&
|
||||
event.total > 0
|
||||
) {
|
||||
updateTTSProgress(event.index, event.total);
|
||||
}
|
||||
speakText(event.data);
|
||||
} else {
|
||||
webViewRef.current?.injectJavaScript('tts.next?.()');
|
||||
}
|
||||
break;
|
||||
case 'pause-speak':
|
||||
Speech.stop();
|
||||
break;
|
||||
case 'stop-speak':
|
||||
Speech.stop();
|
||||
isTTSReadingRef.current = false;
|
||||
ttsQueueRef.current = [];
|
||||
ttsQueueIndexRef.current = 0;
|
||||
dismissTTSNotification();
|
||||
if (!autoStartTTSRef.current) {
|
||||
isTTSReadingRef.current = false;
|
||||
ttsQueueRef.current = [];
|
||||
ttsQueueIndexRef.current = 0;
|
||||
dismissTTSNotification();
|
||||
}
|
||||
break;
|
||||
case 'tts-state':
|
||||
if (event.data && typeof event.data === 'object') {
|
||||
const data = event.data as { isReading?: boolean };
|
||||
const isReading = data.isReading === true;
|
||||
isTTSReadingRef.current = isReading;
|
||||
updateTTSNotification({
|
||||
novelName: novel?.name || 'Unknown',
|
||||
chapterName: chapter.name,
|
||||
isPlaying: isReading,
|
||||
});
|
||||
updateTTSPlaybackState(isReading);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,87 +1,41 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { MMKVStorage } from '@utils/mmkv/mmkv';
|
||||
import { NativeEventEmitter } from 'react-native';
|
||||
import NativeTTSMediaControl from '@specs/NativeTTSMediaControl';
|
||||
|
||||
const TTS_NOTIFICATION_ID = 'tts-control';
|
||||
const TTS_ACTION_KEY = 'TTS_NOTIFICATION_ACTION';
|
||||
|
||||
export const updateTTSCategory = async (isPlaying: boolean) => {
|
||||
await Notifications.setNotificationCategoryAsync('TTS_CONTROLS', [
|
||||
{
|
||||
identifier: 'TTS_PLAY_PAUSE',
|
||||
buttonTitle: isPlaying ? '⏸️ Pause' : '▶️ Play',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'TTS_STOP',
|
||||
buttonTitle: '⏹️ Stop',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
identifier: 'TTS_NEXT',
|
||||
buttonTitle: '⏭️ Next',
|
||||
options: {
|
||||
opensAppToForeground: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
export const ttsMediaEmitter = new NativeEventEmitter(NativeTTSMediaControl);
|
||||
|
||||
export interface TTSNotificationData {
|
||||
novelName: string;
|
||||
chapterName: string;
|
||||
coverUri: string;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
export const showTTSNotification = async (data: TTSNotificationData) => {
|
||||
await updateTTSCategory(data.isPlaying);
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: TTS_NOTIFICATION_ID,
|
||||
content: {
|
||||
title: data.novelName,
|
||||
subtitle: data.chapterName,
|
||||
body: data.isPlaying ? 'Playing' : 'Paused',
|
||||
categoryIdentifier: 'TTS_CONTROLS',
|
||||
sticky: true,
|
||||
sound: false,
|
||||
priority: Notifications.AndroidNotificationPriority.HIGH,
|
||||
},
|
||||
trigger: { seconds: 1, channelId: 'tts-controls' },
|
||||
});
|
||||
export const showTTSNotification = (data: TTSNotificationData) => {
|
||||
NativeTTSMediaControl.showMediaNotification(
|
||||
data.novelName,
|
||||
data.chapterName,
|
||||
data.coverUri,
|
||||
data.isPlaying,
|
||||
);
|
||||
};
|
||||
|
||||
export const updateTTSNotification = async (data: TTSNotificationData) => {
|
||||
await updateTTSCategory(data.isPlaying);
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: TTS_NOTIFICATION_ID,
|
||||
content: {
|
||||
title: data.novelName,
|
||||
subtitle: data.chapterName,
|
||||
body: data.isPlaying ? 'Playing' : 'Paused',
|
||||
categoryIdentifier: 'TTS_CONTROLS',
|
||||
sticky: true,
|
||||
sound: false,
|
||||
priority: Notifications.AndroidNotificationPriority.HIGH,
|
||||
},
|
||||
trigger: { seconds: 1, channelId: 'tts-controls' },
|
||||
});
|
||||
export const updateTTSNotification = (data: TTSNotificationData) => {
|
||||
NativeTTSMediaControl.showMediaNotification(
|
||||
data.novelName,
|
||||
data.chapterName,
|
||||
data.coverUri,
|
||||
data.isPlaying,
|
||||
);
|
||||
};
|
||||
|
||||
export const dismissTTSNotification = async () => {
|
||||
await Notifications.dismissNotificationAsync(TTS_NOTIFICATION_ID);
|
||||
export const updateTTSPlaybackState = (isPlaying: boolean) => {
|
||||
NativeTTSMediaControl.updatePlaybackState(isPlaying);
|
||||
};
|
||||
|
||||
export const setTTSAction = (action: string) => {
|
||||
MMKVStorage.set(TTS_ACTION_KEY, action);
|
||||
export const updateTTSProgress = (current: number, total: number) => {
|
||||
NativeTTSMediaControl.updateProgress(current, total);
|
||||
};
|
||||
|
||||
export const getTTSAction = (): string | undefined => {
|
||||
return MMKVStorage.getString(TTS_ACTION_KEY);
|
||||
};
|
||||
|
||||
export const clearTTSAction = () => {
|
||||
MMKVStorage.delete(TTS_ACTION_KEY);
|
||||
export const dismissTTSNotification = () => {
|
||||
NativeTTSMediaControl.dismiss();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user