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:
Rajarshee Chatterjee
2026-02-20 09:15:54 +05:30
parent b8c177bc55
commit cc0428752b
9 changed files with 528 additions and 165 deletions
-48
View File
@@ -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} />;
}
+1
View File
@@ -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")
+22 -1
View File
@@ -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())
}
@@ -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)
}
}
@@ -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
)
)
}
}
+17
View File
@@ -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');
+77 -46
View File
@@ -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;
}
+24 -70
View File
@@ -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();
};