From cc0428752b2e9869677a6bd2dccc449f7a98a552 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Fri, 20 Feb 2026 09:15:54 +0530 Subject: [PATCH] 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 --- App.tsx | 48 --- android/app/build.gradle | 1 + android/app/src/main/assets/js/core.js | 23 +- .../LNReader/MainApplication.kt | 2 + .../NativeTTSMediaControl.kt | 355 ++++++++++++++++++ .../NativeTTSMediaControlPackage.kt | 30 ++ specs/NativeTTSMediaControl.ts | 17 + .../reader/components/WebViewReader.tsx | 123 +++--- src/utils/ttsNotification.ts | 94 ++--- 9 files changed, 528 insertions(+), 165 deletions(-) create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControl.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControlPackage.kt create mode 100644 specs/NativeTTSMediaControl.ts diff --git a/App.tsx b/App.tsx index eb4d4a6d..0fcfb58f 100644 --- a/App.tsx +++ b/App.tsx @@ -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 null} />; } diff --git a/android/app/build.gradle b/android/app/build.gradle index 637cb5e2..69130129 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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") diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 92118102..a218595d 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -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 { diff --git a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt index 2b547244..16ddc1a2 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt @@ -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 = PackageList(this).packages.apply { add(NativePackage()) + add(NativeTTSMediaControlPackage()) add(NativeVolumeButtonListenerPackage()) add(NativeZipArchivePackage()) } diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControl.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControl.kt new file mode 100644 index 00000000..b7adcc41 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControl.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControlPackage.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControlPackage.kt new file mode 100644 index 00000000..9b87ad07 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeTTSMediaControl/NativeTTSMediaControlPackage.kt @@ -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 + ) + ) + } +} diff --git a/specs/NativeTTSMediaControl.ts b/specs/NativeTTSMediaControl.ts new file mode 100644 index 00000000..8a6622b6 --- /dev/null +++ b/specs/NativeTTSMediaControl.ts @@ -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('NativeTTSMediaControl'); diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 6ac37a91..99befe1c 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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; } diff --git a/src/utils/ttsNotification.ts b/src/utils/ttsNotification.ts index b0d49de6..e5758d9c 100644 --- a/src/utils/ttsNotification.ts +++ b/src/utils/ttsNotification.ts @@ -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(); };