feat: Implement native Android MediaSession and foreground service for TTS playback

- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
This commit is contained in:
2026-04-10 18:56:36 +07:00
parent 2d41121b84
commit 76edaa25a4
9 changed files with 1706 additions and 214 deletions
+5
View File
@@ -4,6 +4,7 @@ import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
@@ -66,6 +67,10 @@ android {
}
}
dependencies {
implementation("androidx.media:media:1.7.0")
}
flutter {
source = "../.."
}
+16 -37
View File
@@ -5,43 +5,6 @@
"storage_bucket": "reader-1658c.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0",
"android_client_info": {
"package_name": "com.example.reader_app"
}
},
"oauth_client": [
{
"client_id": "308259929553-7cdc4g8fe7os799trig7hk7ugkuansov.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.reader_app",
"certificate_hash": "f7e9f7ec9bafd1de69934b2c9b52ee491d73bad7"
}
},
{
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBibgTrvBWtJBL4PGeIyahBwRlYKcjQ47k"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
@@ -50,6 +13,22 @@
}
},
"oauth_client": [
{
"client_id": "308259929553-fd8teopc4chi2jjd8kr5vn9inn35ar6j.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "fa21a3e6a319b71b2dd0ef9573b22046dba5d55c"
}
},
{
"client_id": "308259929553-kdfvnu11cq6k9a2l1b3gtrmfmtsggduk.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "f7e9f7ec9bafd1de69934b2c9b52ee491d73bad7"
}
},
{
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3
+7
View File
@@ -2,6 +2,9 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="reader_app"
android:name="${applicationName}"
@@ -34,6 +37,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".tts.ReaderTtsMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -6,12 +6,19 @@ import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background"
private val mediaChannelName = "reader_app/tts_media"
private val mediaEventsChannelName = "reader_app/tts_media_events"
private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -35,6 +42,117 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"initialize" -> {
val enabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.initialize(this, enabled)
result.success(ReaderTtsMediaBridge.snapshot())
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0
val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.startReading(
this,
parseSegments(call.argument<List<*>>("segments")),
startIndex,
contentKey,
title,
speed,
language,
voiceName,
backgroundModeEnabled,
)
result.success(null)
}
"pause" -> {
ReaderTtsMediaService.pause(this)
result.success(null)
}
"resume" -> {
ReaderTtsMediaService.resume(this)
result.success(null)
}
"stop" -> {
ReaderTtsMediaService.stop(this)
result.success(null)
}
"skipForward" -> {
ReaderTtsMediaService.skipForward(this)
result.success(null)
}
"skipBack" -> {
ReaderTtsMediaService.skipBack(this)
result.success(null)
}
"setSpeed" -> {
val speed = call.argument<Double>("speed") ?: 0.9
ReaderTtsMediaService.setSpeed(this, speed)
result.success(null)
}
"setVoiceByName" -> {
ReaderTtsMediaService.setVoice(
this,
call.argument<String>("voiceName"),
call.argument<String>("language"),
)
result.success(null)
}
"setBackgroundModeEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: true
ReaderTtsMediaService.setBackgroundModeEnabled(this, enabled)
result.success(null)
}
"areNotificationsEnabled" -> {
result.success(NotificationManagerCompat.from(this).areNotificationsEnabled())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(null)
}
"dispose" -> result.success(null)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, mediaEventsChannelName)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
ReaderTtsMediaBridge.attachSink(events)
}
override fun onCancel(arguments: Any?) {
ReaderTtsMediaBridge.detachSink()
}
},
)
}
private fun parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
val segments = arrayListOf<ReaderTtsSegment>()
rawSegments.orEmpty().forEach { item ->
val map = item as? Map<*, *> ?: return@forEach
val text = map["text"]?.toString() ?: return@forEach
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
val start = (map["start"] as? Number)?.toInt() ?: -1
val end = (map["end"] as? Number)?.toInt() ?: -1
segments += ReaderTtsSegment(
text = text,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
return segments
}
private fun isIgnoringBatteryOptimizations(): Boolean {
@@ -76,6 +194,19 @@ class MainActivity : FlutterActivity() {
wakeLock = null
}
private fun openNotificationSettings() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
}
startActivity(intent)
}
override fun onDestroy() {
setWakeLockEnabled(false)
super.onDestroy()
@@ -0,0 +1,44 @@
package com.example.reader_app.tts
import io.flutter.plugin.common.EventChannel
object ReaderTtsMediaBridge {
private var eventSink: EventChannel.EventSink? = null
private var latestSnapshot: Map<String, Any?> = defaultSnapshot()
@Synchronized
fun attachSink(sink: EventChannel.EventSink) {
eventSink = sink
sink.success(HashMap(latestSnapshot))
}
@Synchronized
fun detachSink() {
eventSink = null
}
@Synchronized
fun publish(snapshot: Map<String, Any?>) {
latestSnapshot = HashMap(snapshot)
eventSink?.success(HashMap(latestSnapshot))
}
@Synchronized
fun snapshot(): Map<String, Any?> = HashMap(latestSnapshot)
private fun defaultSnapshot(): Map<String, Any?> = hashMapOf(
"status" to "idle",
"paragraphIndex" to 0,
"totalParagraphs" to 0,
"activeParagraphIndex" to -1,
"progressStart" to -1,
"progressEnd" to -1,
"contentKey" to null,
"completedCount" to 0,
"backgroundModeEnabled" to true,
"language" to "vi-VN",
"voiceName" to null,
"availableVietnameseVoices" to emptyList<Map<String, String>>()
)
}
@@ -0,0 +1,924 @@
package com.example.reader_app.tts
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Parcelable
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media.app.NotificationCompat.MediaStyle
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R
import kotlinx.parcelize.Parcelize
import java.util.Locale
@Parcelize
data class ReaderTtsSegment(
val text: String,
val paragraphIndex: Int,
val start: Int,
val end: Int,
) : Parcelable
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
companion object {
private const val NOTIFICATION_ID = 46021
private const val CHANNEL_ID = "reader_tts_playback"
private const val CHANNEL_NAME = "Reader TTS"
private const val BASE_SPEED = 0.9
private const val TAG = "ReaderTtsMediaService"
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
const val ACTION_INIT = "com.example.reader_app.tts.INIT"
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
const val ACTION_PAUSE = "com.example.reader_app.tts.PAUSE"
const val ACTION_RESUME = "com.example.reader_app.tts.RESUME"
const val ACTION_STOP = "com.example.reader_app.tts.STOP"
const val ACTION_SKIP_FORWARD = "com.example.reader_app.tts.SKIP_FORWARD"
const val ACTION_SKIP_BACK = "com.example.reader_app.tts.SKIP_BACK"
const val ACTION_SET_SPEED = "com.example.reader_app.tts.SET_SPEED"
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE"
const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
const val EXTRA_SEGMENTS = "segments"
const val EXTRA_START_INDEX = "startIndex"
const val EXTRA_CONTENT_KEY = "contentKey"
const val EXTRA_TITLE = "title"
const val EXTRA_SPEED = "speed"
const val EXTRA_LANGUAGE = "language"
const val EXTRA_VOICE_NAME = "voiceName"
const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled"
const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey"
fun initialize(context: Context, backgroundModeEnabled: Boolean) {
context.startService(
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_INIT
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
}
fun startReading(
context: Context,
segments: ArrayList<ReaderTtsSegment>,
startIndex: Int,
contentKey: String?,
title: String?,
speed: Double,
language: String,
voiceName: String?,
backgroundModeEnabled: Boolean,
) {
ContextCompat.startForegroundService(
context,
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_START_READING
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
putExtra(EXTRA_START_INDEX, startIndex)
putExtra(EXTRA_CONTENT_KEY, contentKey)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_SPEED, speed)
putExtra(EXTRA_LANGUAGE, language)
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
}
fun pause(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_PAUSE
})
fun resume(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_RESUME
})
fun stop(context: Context, clearContentKey: Boolean = true) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_STOP
putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey)
})
fun skipForward(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SKIP_FORWARD
})
fun skipBack(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SKIP_BACK
})
fun setSpeed(context: Context, speed: Double) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_SPEED
putExtra(EXTRA_SPEED, speed)
})
fun setVoice(context: Context, voiceName: String?, language: String?) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_VOICE
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_LANGUAGE, language)
})
fun setBackgroundModeEnabled(context: Context, enabled: Boolean) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_BACKGROUND_MODE
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, enabled)
})
}
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var mediaSession: MediaSessionCompat
private lateinit var audioManager: AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var tts: TextToSpeech? = null
private var isTtsReady = false
private var isForegroundActive = false
private var status = "idle"
private var speed = 0.9
private var language = "vi-VN"
private var voiceName: String? = null
private var contentKey: String? = null
private var title: String? = null
private var segments: List<ReaderTtsSegment> = emptyList()
private var currentIndex = 0
private var completedCount = 0
private var backgroundModeEnabled = true
private var availableVoices: List<Map<String, String>> = emptyList()
private var sessionGeneration = 0
private var lastStartedUtterance: String? = null
private var currentUtteranceId: String? = null
private var currentUtteranceStarted = false
private var pendingReplayAfterInit = false
private var currentSegmentRetry = 0
private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null
private var pausedByAudioFocus = false
private var lastSpeakRequestTimeMs = 0L
private val playbackHealthRunnable = object : Runnable {
override fun run() {
runPlaybackHealthCheck()
mainHandler.postDelayed(this, HEALTH_CHECK_INTERVAL_MS)
}
}
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
mainHandler.post {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (status == "playing") {
pausedByAudioFocus = true
handlePause()
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByAudioFocus && status == "paused") {
pausedByAudioFocus = false
handleResume()
}
}
}
}
}
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
createNotificationChannel()
setupMediaSession()
setupTextToSpeech()
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
publishSnapshot()
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_INIT -> {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
publishSnapshot()
}
ACTION_START_READING -> handleStartReading(intent)
ACTION_PAUSE -> handlePause()
ACTION_RESUME -> handleResume()
ACTION_STOP -> handleStop(
clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true),
)
ACTION_SKIP_FORWARD -> handleSkip(1)
ACTION_SKIP_BACK -> handleSkip(-1)
ACTION_SET_SPEED -> {
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
applyVoiceAndSpeedSettings()
publishSnapshot()
}
ACTION_SET_VOICE -> {
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
applyVoiceAndSpeedSettings()
publishSnapshot()
}
ACTION_SET_BACKGROUND_MODE -> {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
syncNotificationState()
publishSnapshot()
}
}
return START_STICKY
}
private fun setupTextToSpeech() {
tts = TextToSpeech(applicationContext, this)
tts?.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
lastStartedUtterance = utteranceId
currentUtteranceStarted = true
currentSegmentRetry = 0
status = "playing"
scheduleUtteranceWatchdog(utteranceId)
syncNotificationState()
publishSnapshot()
}
}
override fun onDone(utteranceId: String?) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState()
handleUtteranceCompleted(parseUtteranceIndex(utteranceId))
}
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
onError(utteranceId, TextToSpeech.ERROR)
}
override fun onError(utteranceId: String?, errorCode: Int) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState()
handlePlaybackFailure()
}
}
},
)
}
override fun onInit(initStatus: Int) {
isTtsReady = initStatus == TextToSpeech.SUCCESS
if (isTtsReady) {
refreshAvailableVoices()
applyVoiceAndSpeedSettings()
if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) {
pendingReplayAfterInit = false
speakCurrentSegment(forceRestart = true)
}
} else {
status = "idle"
}
syncNotificationState()
publishSnapshot()
}
private fun refreshAvailableVoices() {
val ttsInstance = tts ?: return
val vietnameseVoices = ttsInstance.voices
?.filter { voice -> voice.locale?.toLanguageTag()?.lowercase()?.startsWith("vi") == true }
?.mapNotNull { voice ->
val locale = voice.locale?.toLanguageTag() ?: return@mapNotNull null
mapOf("name" to voice.name, "locale" to locale)
}
.orEmpty()
.distinctBy { voice -> "${voice["name"]}:${voice["locale"]}" }
.sortedBy { voice -> voice["name"] }
availableVoices = vietnameseVoices
if (voiceName.isNullOrBlank()) {
val preferred = vietnameseVoices.firstOrNull { voice ->
val normalized = voice["name"]?.lowercase().orEmpty()
normalized.contains("female") || normalized.contains("natural")
} ?: vietnameseVoices.firstOrNull()
voiceName = preferred?.get("name")
language = preferred?.get("locale") ?: language
}
}
private fun applyVoiceAndSpeedSettings() {
val ttsInstance = tts ?: return
ttsInstance.setSpeechRate(speed.toFloat())
val locale = language.toLocale()
ttsInstance.setLanguage(locale)
val matchingVoice = ttsInstance.voices?.firstOrNull { voice ->
voice.name == voiceName && voice.locale?.toLanguageTag() == language
}
if (matchingVoice != null) {
ttsInstance.voice = matchingVoice
}
}
private fun handleStartReading(intent: Intent) {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
contentKey = intent.getStringExtra(EXTRA_CONTENT_KEY)
title = intent.getStringExtra(EXTRA_TITLE)
segments = extractSegments(intent)
currentIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "playing"
pausedByAudioFocus = false
pendingReplayAfterInit = false
tts?.stop()
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handlePause() {
if (status != "playing") return
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "paused"
pendingReplayAfterInit = false
tts?.stop()
syncNotificationState()
publishSnapshot()
}
private fun handleResume() {
if (segments.isEmpty()) return
status = "playing"
sessionGeneration += 1
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handleStop(clearContentKey: Boolean) {
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "idle"
currentIndex = 0
segments = emptyList()
title = null
if (clearContentKey) {
contentKey = null
}
tts?.stop()
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
}
private fun handleSkip(direction: Int) {
if (segments.isEmpty()) return
val nextIndex = (currentIndex + direction).coerceIn(0, segments.lastIndex)
if (nextIndex == currentIndex && status == "idle") return
currentIndex = nextIndex
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "playing"
pendingReplayAfterInit = false
tts?.stop()
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handleUtteranceCompleted(completedIndex: Int) {
if (status != "playing") return
if (completedIndex != currentIndex) return
val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) {
status = "idle"
currentIndex = 0
completedCount += 1
clearUtteranceRuntimeState()
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
return
}
currentIndex = nextIndex
speakCurrentSegment(forceRestart = false)
}
private fun handlePlaybackFailure() {
status = "idle"
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
}
private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return
if (!requestAudioFocus()) {
handlePlaybackFailure()
return
}
val segment = segments.getOrNull(currentIndex) ?: run {
handlePlaybackFailure()
return
}
applyVoiceAndSpeedSettings()
status = "playing"
// Reset retry counter when advancing to a new segment; keep it when retrying same segment.
if (!forceRestart) {
currentSegmentRetry = 0
}
syncNotificationState()
publishSnapshot()
val utteranceId = "${sessionGeneration}:${currentIndex}:${System.nanoTime()}"
lastStartedUtterance = if (forceRestart) null else lastStartedUtterance
currentUtteranceId = utteranceId
currentUtteranceStarted = false
lastSpeakRequestTimeMs = System.currentTimeMillis()
scheduleUtteranceWatchdog(utteranceId)
val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
} else {
@Suppress("DEPRECATION")
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
}
if (speakResult == TextToSpeech.ERROR) {
recoverFromSilentPlayback("speak_error")
}
}
private fun scheduleUtteranceWatchdog(utteranceId: String) {
clearUtteranceWatchdog()
val segment = currentSegment() ?: return
val timeoutMs = estimateUtteranceTimeoutMs(segment.text)
val guard = Runnable {
if (status != "playing") return@Runnable
if (utteranceId != currentUtteranceId) return@Runnable
recoverFromSilentPlayback("watchdog_timeout")
}
utteranceWatchdog = guard
mainHandler.postDelayed(guard, timeoutMs)
}
private fun clearUtteranceWatchdog() {
utteranceWatchdog?.let(mainHandler::removeCallbacks)
utteranceWatchdog = null
}
private fun clearUtteranceRuntimeState() {
clearUtteranceWatchdog()
lastStartedUtterance = null
currentUtteranceId = null
currentUtteranceStarted = false
consecutiveSilentHealthChecks = 0
}
private fun estimateUtteranceTimeoutMs(text: String): Long {
val safeSpeed = speed.coerceIn(0.2, 1.5)
val multiplier = (BASE_SPEED / safeSpeed).coerceIn(0.5, 3.0)
// Use 200ms/char (was 90ms) and a larger 10s buffer so the watchdog does not
// fire prematurely for longer Vietnamese sentences (e.g. ~150 chars ≈ 17 s at 0.9×).
val estimate = (text.length * 200L * multiplier).toLong() + 10_000L
return estimate.coerceIn(15_000L, 180_000L)
}
private fun recoverFromSilentPlayback(reason: String) {
if (status != "playing") return
Log.w(TAG, "Recover from silent playback: $reason (index=$currentIndex retry=$currentSegmentRetry)")
if (segments.isEmpty()) {
handlePlaybackFailure()
return
}
clearUtteranceRuntimeState()
if (currentSegmentRetry >= 2) {
handlePlaybackFailure()
return
}
currentSegmentRetry += 1
if (currentSegmentRetry >= 2) {
rebuildTtsEngineForRecovery(reason)
return
}
tts?.stop()
speakCurrentSegment(forceRestart = true)
}
private fun rebuildTtsEngineForRecovery(reason: String) {
Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason")
pendingReplayAfterInit = true
isTtsReady = false
tts?.stop()
tts?.shutdown()
setupTextToSpeech()
}
private fun runPlaybackHealthCheck() {
if (status != "playing") return
if (segments.isEmpty()) return
val ttsInstance = tts
if (ttsInstance == null) {
rebuildTtsEngineForRecovery("tts_instance_null")
return
}
if (!isTtsReady) {
if (!pendingReplayAfterInit) {
rebuildTtsEngineForRecovery("tts_not_ready")
}
return
}
val isSpeaking = try {
ttsInstance.isSpeaking
} catch (_: Exception) {
false
}
if (!currentUtteranceStarted) {
if (!isSpeaking) {
// Allow a grace period after speak() is called before flagging as silent.
// onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation.
val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs
if (elapsedSinceSpeak > 4_000L) {
recoverFromSilentPlayback("no_onStart_and_not_speaking")
}
}
return
}
if (isSpeaking) {
consecutiveSilentHealthChecks = 0
return
}
consecutiveSilentHealthChecks += 1
if (consecutiveSilentHealthChecks < 2) {
return
}
// Engine stopped speaking but onDone was never delivered; advance manually.
consecutiveSilentHealthChecks = 0
clearUtteranceRuntimeState()
handleUtteranceCompleted(currentIndex)
}
private fun requestAudioFocus(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = audioFocusRequest
?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
.also { audioFocusRequest = it }
audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN,
) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
}
private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusListener)
}
}
private fun isActiveUtterance(utteranceId: String): Boolean {
val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false
return generation == sessionGeneration
}
private fun parseUtteranceIndex(utteranceId: String): Int {
val parts = utteranceId.split(':')
return parts.getOrNull(1)?.toIntOrNull() ?: currentIndex
}
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
private fun currentProgressLabel(): String {
if (segments.isEmpty()) return voiceName ?: language
return "Câu ${currentIndex + 1}/${segments.size}"
}
private fun appLabel(): String = applicationInfo.loadLabel(packageManager).toString()
private fun buildLaunchIntent(): PendingIntent? {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return launchIntent?.let {
PendingIntent.getActivity(
this,
100,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
private fun buildServicePendingIntent(action: String): PendingIntent {
return PendingIntent.getService(
this,
action.hashCode(),
Intent(this, ReaderTtsMediaService::class.java).apply {
this.action = action
if (action == ACTION_STOP) {
putExtra(EXTRA_CLEAR_CONTENT_KEY, true)
}
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent())
.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
.setOnlyAlertOnce(true)
.setOngoing(status == "playing")
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.addAction(
android.R.drawable.ic_media_previous,
"Lùi câu",
buildServicePendingIntent(ACTION_SKIP_BACK),
)
.addAction(
if (status == "playing") android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (status == "playing") "Tạm dừng" else "Tiếp tục",
buildServicePendingIntent(if (status == "playing") ACTION_PAUSE else ACTION_RESUME),
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Dừng",
buildServicePendingIntent(ACTION_STOP),
)
.addAction(
android.R.drawable.ic_media_next,
"Tới câu",
buildServicePendingIntent(ACTION_SKIP_FORWARD),
)
.setStyle(
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 3),
)
.build()
private fun setupMediaSession() {
mediaSession = MediaSessionCompat(this, "ReaderTtsMediaSession")
mediaSession.setCallback(
object : MediaSessionCompat.Callback() {
override fun onPlay() = handleResume()
override fun onPause() = handlePause()
override fun onStop() = handleStop(clearContentKey = true)
override fun onSkipToNext() = handleSkip(1)
override fun onSkipToPrevious() = handleSkip(-1)
},
)
mediaSession.isActive = true
updateMediaSessionState()
}
private fun updateMediaSessionState() {
val playbackState = when (status) {
"playing" -> PlaybackStateCompat.STATE_PLAYING
"paused" -> PlaybackStateCompat.STATE_PAUSED
else -> PlaybackStateCompat.STATE_STOPPED
}
val actions = PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(playbackState, currentIndex.toLong(), 1.0f)
.build(),
)
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: appLabel())
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentProgressLabel())
.build(),
)
}
@SuppressLint("MissingPermission")
private fun syncNotificationState() {
updateMediaSessionState()
if (!backgroundModeEnabled) {
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
notificationManager.cancel(NOTIFICATION_ID)
return
}
when (status) {
"playing" -> {
val notification = buildNotification()
if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
} else {
notificationManager.notify(NOTIFICATION_ID, notification)
}
}
"paused" -> {
val notification = buildNotification()
if (isForegroundActive) {
stopForeground(false)
isForegroundActive = false
}
notificationManager.notify(NOTIFICATION_ID, notification)
}
else -> {
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
notificationManager.cancel(NOTIFICATION_ID)
}
}
}
private fun publishSnapshot() {
val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
ReaderTtsMediaBridge.publish(
hashMapOf(
"status" to status,
"paragraphIndex" to currentIndex,
"totalParagraphs" to segments.size,
"activeParagraphIndex" to if (canExposeSegmentProgress) {
(segment?.paragraphIndex ?: -1)
} else {
-1
},
"progressStart" to if (canExposeSegmentProgress) {
(segment?.start ?: -1)
} else {
-1
},
"progressEnd" to if (canExposeSegmentProgress) {
(segment?.end ?: -1)
} else {
-1
},
"contentKey" to contentKey,
"completedCount" to completedCount,
"backgroundModeEnabled" to backgroundModeEnabled,
"language" to language,
"voiceName" to voiceName,
"availableVietnameseVoices" to availableVoices,
),
)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Điều khiển đọc truyện bằng TTS"
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun extractSegments(intent: Intent): List<ReaderTtsSegment> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java)
?: arrayListOf()
} else {
@Suppress("DEPRECATION")
(intent.getParcelableArrayListExtra<ReaderTtsSegment>(EXTRA_SEGMENTS)
?: arrayListOf())
}
}
override fun onDestroy() {
mainHandler.removeCallbacks(playbackHealthRunnable)
status = "idle"
currentIndex = 0
segments = emptyList()
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
publishSnapshot()
tts?.stop()
tts?.shutdown()
abandonAudioFocus()
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
mediaSession.release()
super.onDestroy()
}
}
private fun String.toLocale(): Locale {
val normalized = replace('_', '-')
return Locale.forLanguageTag(normalized).takeIf { it.language.isNotBlank() }
?: Locale("vi", "VN")
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -74,22 +75,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required bool isActiveParagraph,
required int highlightStart,
required int highlightEnd,
required Function(int charOffset) onSentenceTap,
}) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList();
final safeStart = highlightStart.clamp(0, paragraph.length);
final safeEnd = highlightEnd.clamp(0, paragraph.length);
if (safeEnd <= safeStart) {
if (sentenceMatches.isEmpty) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
onTap: () => onSentenceTap(0),
);
}
@@ -98,16 +93,28 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
fontWeight: FontWeight.w600,
);
return RichText(
textAlign: textAlign,
text: TextSpan(
return SelectableText.rich(
TextSpan(
style: style,
children: [
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)),
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
],
children: sentenceMatches.map((match) {
final sentence = match.group(0)!;
final start = match.start;
final end = match.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
return TextSpan(
text: sentence,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
),
textAlign: textAlign,
);
}
@@ -181,6 +188,48 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
});
}
/// Handle TTS state transitions that require navigation or restarts.
/// Called once from [build] via [ref.listen] — safe to run side effects here.
void _onTtsStateChanged(TtsState? previous, TtsState next) {
// Guard: only act when something meaningful changed.
if (previous == null) return;
final chapterAsync = ref.read(chapterProvider(widget.chapterId));
final chapter = chapterAsync.valueOrNull;
if (chapter == null) return;
// Chapter-completion → auto-advance to next chapter.
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
final nextChapterId = chapter.nextChapterId!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
});
}
return;
}
// Pending auto-start for this chapter (set by previous chapter's completion).
if (next.pendingAutoStartChapterId == chapter.id &&
_autoStartQueuedChapterId != chapter.id) {
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
}
@override
void dispose() {
_uiAutoHideTimer?.cancel();
@@ -686,7 +735,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Wrap(
spacing: 8,
runSpacing: 8,
children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
children: [0.45, 0.675, 0.9, 1.125, 1.35, 1.8].map((speed) {
final selected = tts.speed == speed;
return ChoiceChip(
label: Text(formatTtsSpeedLabel(speed)),
@@ -790,6 +839,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider);
// Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
Color readerBackground;
Color readerTextColor;
Color readerMutedColor;
@@ -835,33 +887,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final shouldHighlightTts = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (tts.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = tts.completedCount;
if (tts.contentKey == chapter.id && chapter.nextChapterId != null) {
final nextChapterId = chapter.nextChapterId!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
});
}
}
if (tts.pendingAutoStartChapterId == chapter.id &&
_autoStartQueuedChapterId != chapter.id) {
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -990,6 +1015,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) {
ref.read(ttsProvider.notifier).startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
),
),
],
@@ -30,7 +30,7 @@ class TtsPlayerWidget extends ConsumerWidget {
final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier);
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0];
const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
Future<void> start() async {
if (tts.status == TtsStatus.paused) {
+483 -115
View File
@@ -7,7 +7,7 @@ import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45;
const double kTtsBaseSpeechRate = 0.9;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
@@ -32,6 +32,13 @@ class _TtsSegment {
final int paragraphIndex;
final int start;
final int end;
Map<String, Object?> toMap() => {
'text': text,
'paragraphIndex': paragraphIndex,
'start': start,
'end': end,
};
}
class TtsVoice {
@@ -65,7 +72,7 @@ class TtsState {
this.paragraphIndex = 0,
this.totalParagraphs = 0,
this.activeParagraphIndex = -1,
this.speed = 0.45,
this.speed = 0.9,
this.language = 'vi-VN',
this.voiceName,
this.availableVietnameseVoices = const [],
@@ -124,17 +131,40 @@ class TtsState {
}
class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
TtsNotifier() : super(const TtsState()) {
_initFuture = _init();
}
static const MethodChannel _backgroundChannel = MethodChannel(
'reader_app/tts_background',
);
static const MethodChannel _mediaChannel = MethodChannel(
'reader_app/tts_media',
);
static const EventChannel _mediaEventsChannel = EventChannel(
'reader_app/tts_media_events',
);
final FlutterTts _tts = FlutterTts();
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
StreamSubscription<dynamic>? _mediaEventsSub;
int _playbackGeneration = 0;
bool _isInterruptingPlayback = false;
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
Future<void> _init() async {
if (_useNativeAndroidMediaService) {
await _initAndroidBridge();
_initialized = true;
return;
}
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
@@ -150,39 +180,85 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
if (Platform.isAndroid) {
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _configureVietnameseVoiceWithFlutterTts();
await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
} else {
state = state.copyWith(status: TtsStatus.playing);
}
unawaited(_syncBackgroundMode());
});
_tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) {
_next();
}
// Fallback playback progression is driven by _playFallbackFromGeneration.
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle);
if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
unawaited(_syncBackgroundMode());
});
await _syncBackgroundMode();
_initialized = true;
}
Future<void> _configureVietnameseVoice() async {
Future<void> _initAndroidBridge() async {
_mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen(
_handleAndroidMediaEvent,
onError: (_) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
},
);
await _mediaChannel.invokeMethod<void>('initialize', {
'backgroundModeEnabled': state.backgroundModeEnabled,
});
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
await _ensureAndroidMediaNotificationsEnabled();
}
Future<void> _ensureAndroidMediaNotificationsEnabled() async {
if (!_useNativeAndroidMediaService) return;
if (_hasPromptedNotificationSettings) return;
final enabled = await _mediaChannel.invokeMethod<bool>('areNotificationsEnabled') ?? true;
if (enabled) return;
_hasPromptedNotificationSettings = true;
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices;
String? selectedName;
@@ -191,22 +267,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase();
final locale = (voice['locale'] ?? voice['language'] ?? '')
.toString()
.toLowerCase();
return locale.startsWith('vi');
}).toList();
for (final voice in vietnamese) {
final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue;
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
vietnameseVoices.add(TtsVoice(name: name, locale: locale));
}
if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere(
(voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) ||
(voice['name']?.toString().toLowerCase().contains('natural') ?? false),
(voice['name']
?.toString()
.toLowerCase()
.contains('female') ??
false) ||
(voice['name']
?.toString()
.toLowerCase()
.contains('natural') ??
false),
orElse: () => vietnamese.first,
);
selectedName = preferred['name']?.toString();
@@ -219,6 +307,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
}
state = state.copyWith(
language: selectedLanguage,
voiceName: selectedName,
@@ -226,7 +315,159 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
void _applyAndroidSnapshot(dynamic snapshot) {
if (snapshot is! Map) return;
final data = Map<String, dynamic>.from(
snapshot.map((key, value) => MapEntry(key.toString(), value)),
);
final statusRaw = data['status']?.toString() ?? 'idle';
final status = switch (statusRaw) {
'playing' => TtsStatus.playing,
'paused' => TtsStatus.paused,
_ => TtsStatus.idle,
};
final voicesRaw = data['availableVietnameseVoices'];
final voices = <TtsVoice>[];
if (voicesRaw is List) {
for (final item in voicesRaw) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(
item.map((key, value) => MapEntry(key.toString(), value)),
);
final name = map['name']?.toString();
final locale = map['locale']?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
voices.add(TtsVoice(name: name, locale: locale));
}
}
state = state.copyWith(
status: status,
paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0,
totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0,
activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1,
progressStart: (data['progressStart'] as num?)?.toInt() ?? -1,
progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1,
contentKey: data['contentKey']?.toString(),
completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount,
language: data['language']?.toString() ?? state.language,
voiceName: data['voiceName']?.toString(),
availableVietnameseVoices: voices,
backgroundModeEnabled:
data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled,
);
}
List<_TtsSegment> _buildSegments(
String content, {
String? title,
bool includeTitle = true,
}) {
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
segments.add(
_TtsSegment(
text: titleText,
paragraphIndex: -1,
start: -1,
end: -1,
),
);
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) {
start = cursor.clamp(0, paragraph.length);
}
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
return segments;
}
int _resolveStartIndex(
int paragraphIndex, {
int? startParagraphIndex,
int? startCharOffset,
}) {
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final matchIndex = _segments.indexWhere(
(segment) =>
segment.paragraphIndex == startParagraphIndex &&
(startCharOffset == null || segment.start >= startCharOffset),
);
if (matchIndex >= 0) {
validIndex = matchIndex;
} else {
final fallbackIndex = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
);
if (fallbackIndex >= 0) {
validIndex = fallbackIndex;
}
}
}
return validIndex;
}
Future<void> setVoiceByName(String voiceName) async {
if (_useNativeAndroidMediaService) {
final selected = state.availableVietnameseVoices.where(
(voice) => voice.name == voiceName,
);
if (selected.isEmpty) return;
final voice = selected.first;
await _mediaChannel.invokeMethod<void>('setVoiceByName', {
'voiceName': voice.name,
'language': voice.locale,
});
state = state.copyWith(language: voice.locale, voiceName: voice.name);
return;
}
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return;
@@ -240,6 +481,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
'enabled': enabled,
});
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
return;
}
await _syncBackgroundMode();
if (enabled) {
await ensureBatteryOptimizationIgnored();
@@ -278,23 +529,24 @@ class TtsNotifier extends StateNotifier<TtsState> {
}
Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return;
if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing;
try {
await _backgroundChannel
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive});
await _backgroundChannel.invokeMethod<void>('setWakeLock', {
'enabled': shouldKeepAlive,
});
} catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable.
}
}
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading(
String content, {
int paragraphIndex = 0,
int? startParagraphIndex,
int? startCharOffset,
String? contentKey,
String? title,
bool includeTitle = true,
@@ -303,57 +555,38 @@ class TtsNotifier extends StateNotifier<TtsState> {
await (_initFuture ?? _init());
}
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1));
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) start = cursor.clamp(0, paragraph.length);
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
_segments = _buildSegments(
content,
title: title,
includeTitle: includeTitle,
);
}
}
_segments = segments;
if (_segments.isEmpty) return;
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final startFromVisible = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
if (_segments.isEmpty) {
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
totalParagraphs: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
);
if (startFromVisible >= 0) {
validIndex = startFromVisible;
if (!_useNativeAndroidMediaService) {
await _syncBackgroundMode();
}
return;
}
final validIndex = _resolveStartIndex(
paragraphIndex,
startParagraphIndex: startParagraphIndex,
startCharOffset: startCharOffset,
);
final selectedSegment = _segments[validIndex];
if (_useNativeAndroidMediaService) {
await _ensureAndroidMediaNotificationsEnabled();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
@@ -363,12 +596,91 @@ class TtsNotifier extends StateNotifier<TtsState> {
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
await _syncBackgroundMode();
await _speak(validIndex);
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
return;
}
Future<void> _speak(int index) async {
if (index >= _segments.length) {
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
);
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<int> _interruptFallbackPlayback() async {
_playbackGeneration++;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
_isInterruptingPlayback = true;
try {
await _tts.stop();
if (Platform.isAndroid) {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
} finally {
_isInterruptingPlayback = false;
}
return _playbackGeneration;
}
Future<void> _playFallbackFromGeneration(int startIndex, int generation) async {
if (startIndex < 0 || startIndex >= _segments.length) {
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
for (var index = startIndex; index < _segments.length; index++) {
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
_pendingFallbackIndex = index;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
await _tts.setSpeechRate(state.speed);
final result = await _tts.speak(_segments[index].text);
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
if (result is int && result != 1) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
@@ -379,21 +691,23 @@ class TtsNotifier extends StateNotifier<TtsState> {
return;
}
final segment = _segments[index];
if (!_didStartCurrentFallbackUtterance) {
state = state.copyWith(
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _tts.setSpeechRate(state.speed);
await _tts.speak(segment.text);
await _syncBackgroundMode();
return;
}
}
Future<void> _next() async {
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) {
if (generation != _playbackGeneration) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
@@ -403,33 +717,66 @@ class TtsNotifier extends StateNotifier<TtsState> {
completedCount: state.completedCount + 1,
);
await _syncBackgroundMode();
return;
}
state = state.copyWith(
paragraphIndex: next,
activeParagraphIndex: _segments[next].paragraphIndex,
progressStart: _segments[next].start,
progressEnd: _segments[next].end,
);
await _speak(next);
}
Future<void> pause() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('pause');
return;
}
if (state.status != TtsStatus.playing) return;
_playbackGeneration++;
await _tts.pause();
state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode();
}
Future<void> resume() async {
if (state.status != TtsStatus.paused) return;
state = state.copyWith(status: TtsStatus.playing);
Future<void> _restartFallbackFromIndex(int index) async {
if (_segments.isEmpty) return;
final sessionId = await _interruptFallbackPlayback();
final validIndex = index.clamp(0, _segments.length - 1);
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<void> resume() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('resume');
return;
}
if (state.status != TtsStatus.paused) return;
await _restartFallbackFromIndex(state.paragraphIndex);
}
Future<void> stop() async {
await _tts.stop();
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('stop');
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
clearContentKey: true,
);
return;
}
await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
@@ -442,32 +789,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
}
Future<void> skipForward() async {
await _tts.stop();
await _next();
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('skipForward');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final next = state.paragraphIndex + 1;
if (next >= _segments.length) {
await stop();
return;
}
await _restartFallbackFromIndex(next);
}
Future<void> skipBack() async {
await _tts.stop();
if (state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
state = state.copyWith(
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex,
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end,
);
if (state.status == TtsStatus.playing) await _speak(prev);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('skipBack');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
await _restartFallbackFromIndex(prev);
}
Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
return;
}
await _tts.setSpeechRate(speed);
}
@override
void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false}));
_tts.stop();
_mediaEventsSub?.cancel();
if (_useNativeAndroidMediaService) {
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
} else {
unawaited(_tts.stop());
}
super.dispose();
}
}