feat: Implement TTS playback store and enhance reading progress synchronization
Build Android APK / build-apk (push) Has been cancelled
Build Android AAB / build-aab (push) Has been cancelled

- Added ReaderTtsPlaybackStore to manage TTS start requests with a maximum of 4 pending requests.
- Updated app configuration to use a production API URL.
- Enhanced BookmarkModel to infer type when not provided by the API for backward compatibility.
- Introduced methods in LocalStore for saving, loading, and clearing the last route path.
- Implemented syncProgress method in BookshelfNotifier to update reading progress and bookmarks from the server.
- Modified ReaderScreen to handle chapter navigation and TTS playback more effectively, including auto-start logic.
- Updated TtsPlayerWidget to accept additional parameters for chapter navigation.
- Enhanced TtsNotifier to handle new parameters for TTS requests and manage playback state.
- Improved SplashScreen to restore the last visited route after splash screen display.
This commit is contained in:
2026-04-27 00:48:05 +07:00
parent 66613857e8
commit c3e6d66f43
14 changed files with 758 additions and 128 deletions
+8
View File
@@ -115,3 +115,11 @@ Optional (iOS/web):
```bash ```bash
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com --dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
``` ```
Noted:
Với MIUI:
Cần hướng dẫn user (không thể fix bằng code)
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment import com.example.reader_app.tts.ReaderTtsStartRequest
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background" private val channelName = "reader_app/tts_background"
@@ -53,23 +53,34 @@ class MainActivity : FlutterActivity() {
} }
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot()) "getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> { "startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0 val content = call.argument<String>("content") ?: ""
val contentKey = call.argument<String>("contentKey") val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title") val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9 val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN" val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName") val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
val nextChapterId = call.argument<String>("nextChapterId")
val chapterNumber = call.argument<Int>("chapterNumber")
val includeTitle = call.argument<Boolean>("includeTitle") ?: true
val apiBaseUrl = call.argument<String>("apiBaseUrl")
val startIndex = call.argument<Int>("startIndex") ?: 0
ReaderTtsMediaService.startReading( ReaderTtsMediaService.startReading(
this, this,
parseSegments(call.argument<List<*>>("segments")), ReaderTtsStartRequest(
startIndex, content = content,
contentKey, contentKey = contentKey,
title, title = title,
speed, speed = speed,
language, language = language,
voiceName, voiceName = voiceName,
backgroundModeEnabled, backgroundModeEnabled = backgroundModeEnabled,
nextChapterId = nextChapterId,
chapterNumber = chapterNumber,
includeTitle = includeTitle,
apiBaseUrl = apiBaseUrl,
startIndex = startIndex,
),
) )
result.success(null) result.success(null)
} }
@@ -137,24 +148,6 @@ class MainActivity : FlutterActivity() {
) )
} }
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 { private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -16,7 +16,6 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.Parcelable
import android.os.PowerManager import android.os.PowerManager
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
@@ -29,17 +28,27 @@ import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R import com.example.reader_app.R
import kotlinx.parcelize.Parcelize import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.min import kotlin.math.min
import java.util.Locale import java.util.Locale
import java.util.concurrent.Executors
@Parcelize
data class ReaderTtsSegment( data class ReaderTtsSegment(
val text: String, val text: String,
val paragraphIndex: Int, val paragraphIndex: Int,
val start: Int, val start: Int,
val end: Int, val end: Int,
) : Parcelable )
private data class ReaderRemoteChapter(
val id: String,
val number: Int?,
val title: String?,
val content: String,
val nextChapterId: String?,
)
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
companion object { companion object {
@@ -51,6 +60,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private const val HEALTH_CHECK_INTERVAL_MS = 1500L private const val HEALTH_CHECK_INTERVAL_MS = 1500L
private const val START_GRACE_PERIOD_MS = 5_000L private const val START_GRACE_PERIOD_MS = 5_000L
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4 private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
private const val DEDUPE_START_WINDOW_MS = 600L
const val ACTION_INIT = "com.example.reader_app.tts.INIT" 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_START_READING = "com.example.reader_app.tts.START_READING"
@@ -63,8 +73,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE" 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 ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
const val EXTRA_SEGMENTS = "segments" const val EXTRA_SESSION_TOKEN = "sessionToken"
const val EXTRA_START_INDEX = "startIndex"
const val EXTRA_CONTENT_KEY = "contentKey" const val EXTRA_CONTENT_KEY = "contentKey"
const val EXTRA_TITLE = "title" const val EXTRA_TITLE = "title"
const val EXTRA_SPEED = "speed" const val EXTRA_SPEED = "speed"
@@ -84,30 +93,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
) )
} }
fun startReading( fun startReading(context: Context, request: ReaderTtsStartRequest): Boolean {
context: Context,
segments: ArrayList<ReaderTtsSegment>,
startIndex: Int,
contentKey: String?,
title: String?,
speed: Double,
language: String,
voiceName: String?,
backgroundModeEnabled: Boolean,
): Boolean {
return try { return try {
val sessionToken = ReaderTtsPlaybackStore.enqueue(request)
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
Intent(context, ReaderTtsMediaService::class.java).apply { Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_START_READING action = ACTION_START_READING
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments) putExtra(EXTRA_SESSION_TOKEN, sessionToken)
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)
}, },
) )
true true
@@ -201,7 +194,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private var consecutiveSilentHealthChecks = 0 private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null private var utteranceWatchdog: Runnable? = null
private var pausedByAudioFocus = false private var pausedByAudioFocus = false
private var isDuckedByAudioFocus = false
private var volumeMultiplier = 1.0f
private var lastSpeakRequestTimeMs = 0L private var lastSpeakRequestTimeMs = 0L
private var nextChapterId: String? = null
private var chapterNumber: Int? = null
private var includeChapterTitleInPlayback = true
private var apiBaseUrl: String? = null
private var isPreparingNextChapter = false
private var lastStartSignature: String? = null
private var lastStartRequestAtMs = 0L
private val networkExecutor = Executors.newSingleThreadExecutor()
private val playbackHealthRunnable = object : Runnable { private val playbackHealthRunnable = object : Runnable {
override fun run() { override fun run() {
runPlaybackHealthCheck() runPlaybackHealthCheck()
@@ -214,15 +217,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
when (focusChange) { when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
clearDuckingState(restartPlayback = false)
if (status == "playing") { if (status == "playing") {
pausedByAudioFocus = true pausedByAudioFocus = true
handlePause() handlePause()
} }
} }
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> handleDuckAudioFocusLoss()
AudioManager.AUDIOFOCUS_GAIN -> { AudioManager.AUDIOFOCUS_GAIN -> {
val shouldRestorePlaybackVolume = isDuckedByAudioFocus
clearDuckingState(
restartPlayback = shouldRestorePlaybackVolume && status == "playing",
)
if (pausedByAudioFocus && status == "paused") { if (pausedByAudioFocus && status == "paused") {
pausedByAudioFocus = false pausedByAudioFocus = false
handleResume() handleResume()
} else if (pausedByAudioFocus && status == "playing") {
// Delayed focus grant arrived while status was already "playing"
// (set optimistically). Treat same as resume.
pausedByAudioFocus = false
clearAudioFocusRetry()
speakCurrentSegment(forceRestart = true)
} }
} }
} }
@@ -236,6 +251,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
createNotificationChannel() createNotificationChannel()
setupMediaSession() setupMediaSession()
// Call startForeground() IMMEDIATELY in onCreate() before any async work.
// Android O+ (and MIUI strictly enforced) requires startForeground() to be called
// within 5 seconds of startForegroundService(). TTS engine init is async and may
// take longer on cold start / low-end devices, so we must not wait for it.
isForegroundActive = startForegroundCompat(buildIdleNotification())
setupTextToSpeech() setupTextToSpeech()
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS) mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
publishSnapshot() publishSnapshot()
@@ -264,13 +284,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
ACTION_SKIP_BACK -> handleSkip(-1) ACTION_SKIP_BACK -> handleSkip(-1)
ACTION_SET_SPEED -> { ACTION_SET_SPEED -> {
speed = intent.getDoubleExtra(EXTRA_SPEED, speed) speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
applyVoiceAndSpeedSettings() if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot() publishSnapshot()
} }
ACTION_SET_VOICE -> { ACTION_SET_VOICE -> {
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME) voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
applyVoiceAndSpeedSettings() if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot() publishSnapshot()
} }
ACTION_SET_BACKGROUND_MODE -> { ACTION_SET_BACKGROUND_MODE -> {
@@ -411,21 +435,53 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handleStartReading(intent: Intent) { private fun handleStartReading(intent: Intent) {
val request = ReaderTtsPlaybackStore.consume(intent.getStringExtra(EXTRA_SESSION_TOKEN))
if (request == null) {
Log.e(TAG, "Missing in-memory TTS start request; refusing to start playback")
handleStop(clearContentKey = true, reason = "missing_start_request")
return
}
val now = System.currentTimeMillis()
val signature = listOf(
request.contentKey ?: "",
request.title ?: "",
request.chapterNumber?.toString() ?: "",
request.startIndex.toString(),
request.includeTitle.toString(),
request.content.length.toString(),
).joinToString("|")
val isDuplicateRapidStart =
signature == lastStartSignature &&
(now - lastStartRequestAtMs) in 0..DEDUPE_START_WINDOW_MS
if (isDuplicateRapidStart && (status == "playing" || status == "paused")) {
Log.w(TAG, "Ignore duplicated rapid START_READING request")
return
}
lastStartSignature = signature
lastStartRequestAtMs = now
cancelIdleStop() cancelIdleStop()
backgroundModeEnabled = intent.getBooleanExtra( backgroundModeEnabled = request.backgroundModeEnabled
EXTRA_BACKGROUND_MODE_ENABLED, speed = request.speed
backgroundModeEnabled, language = request.language
voiceName = request.voiceName
contentKey = request.contentKey
title = request.title
nextChapterId = request.nextChapterId
chapterNumber = request.chapterNumber
includeChapterTitleInPlayback = request.includeTitle
apiBaseUrl = request.apiBaseUrl?.trimEnd('/')
segments = buildSegments(
content = request.content,
title = request.title,
includeTitle = includeChapterTitleInPlayback,
) )
speed = intent.getDoubleExtra(EXTRA_SPEED, speed) currentIndex = request.startIndex.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
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 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "playing" status = "playing"
pausedByAudioFocus = false pausedByAudioFocus = false
pendingReplayAfterInit = false pendingReplayAfterInit = false
@@ -433,6 +489,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
syncPowerState() syncPowerState()
publishSnapshot() publishSnapshot()
if (segments.isEmpty()) {
handleStop(clearContentKey = false, reason = "empty_segments")
return
}
if (!isTtsReady) return if (!isTtsReady) return
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
@@ -442,6 +503,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
status = "paused" status = "paused"
isPreparingNextChapter = false
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
syncPowerState() syncPowerState()
@@ -453,6 +515,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (segments.isEmpty()) return if (segments.isEmpty()) return
cancelIdleStop() cancelIdleStop()
status = "playing" status = "playing"
isPreparingNextChapter = false
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
@@ -468,10 +531,15 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
clearScheduledRecoveries() clearScheduledRecoveries()
cancelIdleStop() cancelIdleStop()
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
segments = emptyList() segments = emptyList()
title = null title = null
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
if (clearContentKey) { if (clearContentKey) {
contentKey = null contentKey = null
} }
@@ -490,6 +558,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
currentIndex = nextIndex currentIndex = nextIndex
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing" status = "playing"
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
@@ -505,16 +574,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val nextIndex = currentIndex + 1 val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) { if (nextIndex >= segments.size) {
status = "idle" handleChapterCompleted()
currentIndex = 0
completedCount += 1
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
clearUtteranceRuntimeState()
abandonAudioFocus()
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleIdleStop()
return return
} }
@@ -542,6 +602,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun speakCurrentSegment(forceRestart: Boolean) { private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return if (segments.isEmpty() || !isTtsReady) return
isPreparingNextChapter = false
if (!requestAudioFocus()) { if (!requestAudioFocus()) {
pausedByAudioFocus = true pausedByAudioFocus = true
status = "paused" status = "paused"
@@ -576,10 +637,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
scheduleUtteranceWatchdog(utteranceId) scheduleUtteranceWatchdog(utteranceId)
val speakResult = try { val speakResult = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId) val params = Bundle().apply {
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volumeMultiplier)
}
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, params, utteranceId)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null) tts?.speak(
segment.text,
TextToSpeech.QUEUE_FLUSH,
hashMapOf(
TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID to utteranceId,
TextToSpeech.Engine.KEY_PARAM_VOLUME to volumeMultiplier.toString(),
),
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "speak() failed for index=$currentIndex", e) Log.e(TAG, "speak() failed for index=$currentIndex", e)
@@ -761,10 +832,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
.build(), .build(),
) )
.setAcceptsDelayedFocusGain(true) .setAcceptsDelayedFocusGain(true)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener(audioFocusListener) .setOnAudioFocusChangeListener(audioFocusListener)
.build() .build()
.also { audioFocusRequest = it } .also { audioFocusRequest = it }
val result = audioManager.requestAudioFocus(request) val result = audioManager.requestAudioFocus(request)
// AUDIOFOCUS_REQUEST_DELAYED (= 2) means focus will arrive via the listener.
// Treat it as "not yet granted" the listener will resume playback on GAIN.
result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -846,7 +920,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
wakeLock = powerManager.newWakeLock( wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
"reader_app:ReaderTtsPlayback" "$packageName:ReaderTtsPlayback"
).apply { ).apply {
setReferenceCounted(false) setReferenceCounted(false)
acquire() acquire()
@@ -875,6 +949,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex) private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
private fun currentProgressLabel(): String { private fun currentProgressLabel(): String {
if (isPreparingNextChapter) return "Đang tải chương tiếp theo"
if (segments.isEmpty()) return voiceName ?: language if (segments.isEmpty()) return voiceName ?: language
return "Câu ${currentIndex + 1}/${segments.size}" return "Câu ${currentIndex + 1}/${segments.size}"
} }
@@ -910,6 +985,16 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
) )
} }
/** Minimal notification used in onCreate() to satisfy the 5-second startForeground() rule. */
private fun buildIdleNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(appLabel())
.setContentText("Đang khởi động TTS…")
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs. // Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
@@ -1071,6 +1156,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"contentKey" to contentKey, "contentKey" to contentKey,
"completedCount" to completedCount, "completedCount" to completedCount,
"backgroundModeEnabled" to backgroundModeEnabled, "backgroundModeEnabled" to backgroundModeEnabled,
"isPreparingNextChapter" to isPreparingNextChapter,
"language" to language, "language" to language,
"voiceName" to voiceName, "voiceName" to voiceName,
"availableVietnameseVoices" to availableVoices, "availableVietnameseVoices" to availableVoices,
@@ -1084,23 +1170,254 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
CHANNEL_NAME, CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW, // IMPORTANCE_DEFAULT is required on MIUI 12+ so the system does not demote
// the foreground service. Sound/vibration are disabled explicitly so the user
// is not disturbed despite the higher importance level.
NotificationManager.IMPORTANCE_DEFAULT,
).apply { ).apply {
description = "Điều khiển đọc truyện bằng TTS" description = "Điều khiển đọc truyện bằng TTS"
setShowBadge(false) setShowBadge(false)
setSound(null, null)
enableLights(false)
enableVibration(false)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
private fun extractSegments(intent: Intent): List<ReaderTtsSegment> { private fun sanitizeForTts(raw: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (raw.isBlank()) return raw
intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java) return raw
?: arrayListOf() .replace(Regex("[\"“”]"), " ")
} else { .replace(Regex("[_\\$#^*+=~`|<>\\\\\\[\\]{}]"), " ")
@Suppress("DEPRECATION") .replace(Regex("\\s+"), " ")
(intent.getParcelableArrayListExtra<ReaderTtsSegment>(EXTRA_SEGMENTS) .trim()
?: arrayListOf()) }
private fun buildSegments(
content: String,
title: String?,
includeTitle: Boolean,
): List<ReaderTtsSegment> {
val builtSegments = mutableListOf<ReaderTtsSegment>()
val trimmedTitle = title?.trim().orEmpty()
if (includeTitle && trimmedTitle.isNotEmpty()) {
val sanitizedTitle = sanitizeForTts(trimmedTitle)
if (sanitizedTitle.isNotEmpty()) {
builtSegments += ReaderTtsSegment(
text = sanitizedTitle,
paragraphIndex = -1,
start = -1,
end = -1,
)
}
} }
val paragraphs = content
.split(Regex("\\n+"))
.map(String::trim)
.filter(String::isNotEmpty)
val sentenceRegex = Regex("[^.!?…]+[.!?…]*")
paragraphs.forEachIndexed { paragraphIndex, paragraph ->
var cursor = 0
sentenceRegex.findAll(paragraph).forEach { match ->
val sentence = match.value.trim()
if (sentence.isEmpty()) return@forEach
val sanitizedSentence = sanitizeForTts(sentence)
if (sanitizedSentence.isEmpty()) return@forEach
var start = paragraph.indexOf(sentence, cursor)
if (start < 0) {
start = cursor.coerceIn(0, paragraph.length)
}
val end = (start + sentence.length).coerceIn(0, paragraph.length)
cursor = end
builtSegments += ReaderTtsSegment(
text = sanitizedSentence,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
}
return builtSegments
}
private fun handleChapterCompleted() {
clearUtteranceRuntimeState()
val nextId = nextChapterId
if (nextId.isNullOrBlank() || apiBaseUrl.isNullOrBlank()) {
finishPlaybackAfterChapterCompletion()
return
}
isPreparingNextChapter = true
syncNotificationState()
publishSnapshot()
fetchAndPlayNextChapter(nextId, sessionGeneration)
}
private fun finishPlaybackAfterChapterCompletion() {
status = "idle"
currentIndex = 0
completedCount += 1
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
abandonAudioFocus()
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleIdleStop()
}
private fun fetchAndPlayNextChapter(chapterId: String, generation: Int) {
networkExecutor.execute {
val remoteChapter = fetchChapterWithRetries(chapterId)
mainHandler.post {
if (generation != sessionGeneration || status == "idle") return@post
if (remoteChapter == null) {
Log.e(TAG, "Failed to fetch next chapter chapterId=$chapterId")
isPreparingNextChapter = false
finishPlaybackAfterChapterCompletion()
return@post
}
adoptRemoteChapter(remoteChapter)
}
}
}
private fun fetchChapterWithRetries(chapterId: String): ReaderRemoteChapter? {
repeat(3) { attempt ->
try {
return fetchChapter(chapterId)
} catch (error: Throwable) {
Log.w(TAG, "fetch_next_chapter_failed attempt=${attempt + 1} chapterId=$chapterId", error)
if (attempt < 2) {
Thread.sleep((750L * (attempt + 1)).coerceAtMost(2_500L))
}
}
}
return null
}
private fun fetchChapter(chapterId: String): ReaderRemoteChapter {
val baseUrl = apiBaseUrl?.trimEnd('/') ?: error("Missing api base URL for TTS service")
val connection = (URL("$baseUrl/api/chapters/$chapterId").openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 20_000
readTimeout = 20_000
setRequestProperty("Accept", "application/json")
}
try {
val statusCode = connection.responseCode
val responseBody = (if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
})?.bufferedReader()?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
error("HTTP $statusCode when fetching chapter $chapterId: $responseBody")
}
val json = JSONObject(responseBody)
val id = json.optString("id").takeIf { it.isNotBlank() }
?: error("Chapter payload missing id")
val title = json.optString("title").takeIf { it.isNotBlank() }
val content = json.optString("content")
val nextId = json.optString("nextChapterId").takeIf { it.isNotBlank() }
val number = if (json.isNull("number")) null else json.optInt("number")
return ReaderRemoteChapter(
id = id,
number = number,
title = title,
content = content,
nextChapterId = nextId,
)
} finally {
connection.disconnect()
}
}
private fun adoptRemoteChapter(remoteChapter: ReaderRemoteChapter) {
val nextTitle = buildChapterTitle(remoteChapter.number, remoteChapter.title)
val nextSegments = buildSegments(
content = remoteChapter.content,
title = nextTitle,
includeTitle = includeChapterTitleInPlayback,
)
if (nextSegments.isEmpty()) {
Log.e(TAG, "Fetched next chapter has no readable segments id=${remoteChapter.id}")
isPreparingNextChapter = false
finishPlaybackAfterChapterCompletion()
return
}
completedCount += 1
contentKey = remoteChapter.id
title = nextTitle
chapterNumber = remoteChapter.number
nextChapterId = remoteChapter.nextChapterId
segments = nextSegments
currentIndex = 0
sessionGeneration += 1
clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing"
pausedByAudioFocus = false
pendingReplayAfterInit = false
clearDuckingState(restartPlayback = false)
publishSnapshot()
if (!isTtsReady) {
pendingReplayAfterInit = true
scheduleEngineRebuild("next_chapter_tts_not_ready")
return
}
speakCurrentSegment(forceRestart = true)
}
private fun buildChapterTitle(number: Int?, rawTitle: String?): String? {
val trimmedTitle = rawTitle?.trim().orEmpty()
return when {
number != null && trimmedTitle.isNotEmpty() -> "Chương $number: $trimmedTitle"
number != null -> "Chương $number"
trimmedTitle.isNotEmpty() -> trimmedTitle
else -> null
}
}
private fun handleDuckAudioFocusLoss() {
pausedByAudioFocus = false
if (isDuckedByAudioFocus) return
isDuckedByAudioFocus = true
volumeMultiplier = 0.35f
if (status == "playing" && !isPreparingNextChapter) {
restartCurrentSegmentForFocusChange()
}
}
private fun clearDuckingState(restartPlayback: Boolean) {
if (!isDuckedByAudioFocus && volumeMultiplier == 1.0f) return
isDuckedByAudioFocus = false
volumeMultiplier = 1.0f
if (restartPlayback && status == "playing" && !isPreparingNextChapter) {
restartCurrentSegmentForFocusChange()
}
}
private fun restartCurrentSegmentForFocusChange() {
if (segments.isEmpty() || !isTtsReady) return
sessionGeneration += 1
clearUtteranceRuntimeState()
tts?.stop()
speakCurrentSegment(forceRestart = true)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -1111,8 +1428,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
segments = emptyList() segments = emptyList()
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
isPreparingNextChapter = false
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
clearDuckingState(restartPlayback = false)
publishSnapshot() publishSnapshot()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
@@ -1123,6 +1445,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
isForegroundActive = false isForegroundActive = false
} }
mediaSession.release() mediaSession.release()
networkExecutor.shutdownNow()
super.onDestroy() super.onDestroy()
} }
} }
@@ -0,0 +1,41 @@
package com.example.reader_app.tts
import java.util.LinkedHashMap
import java.util.UUID
data class ReaderTtsStartRequest(
val content: String,
val contentKey: String?,
val title: String?,
val speed: Double,
val language: String,
val voiceName: String?,
val backgroundModeEnabled: Boolean,
val nextChapterId: String?,
val chapterNumber: Int?,
val includeTitle: Boolean,
val apiBaseUrl: String?,
val startIndex: Int = 0,
)
object ReaderTtsPlaybackStore {
private const val MAX_PENDING_REQUESTS = 4
private val pendingRequests = LinkedHashMap<String, ReaderTtsStartRequest>()
@Synchronized
fun enqueue(request: ReaderTtsStartRequest): String {
val token = UUID.randomUUID().toString()
pendingRequests[token] = request
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
val oldestKey = pendingRequests.entries.firstOrNull()?.key ?: break
pendingRequests.remove(oldestKey)
}
return token
}
@Synchronized
fun consume(token: String?): ReaderTtsStartRequest? {
if (token.isNullOrBlank()) return null
return pendingRequests.remove(token)
}
}
+18
View File
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart'; import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart'; import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart'; import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart'; import 'router/route_names.dart';
@@ -19,10 +23,23 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> { class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub; ProviderSubscription<int>? _sessionExpirySub;
late final GoRouter _router;
void _persistRouteForRestore() {
if (!mounted) return;
unawaited(() async {
final uri = _router.state.uri;
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
if (fullPath == RouteNames.splash) return;
await ref.read(localStoreProvider).saveLastRoutePath(fullPath);
}());
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements(); _ensureMandatoryTtsRequirements();
}); });
@@ -92,6 +109,7 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
@override @override
void dispose() { void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close(); _sessionExpirySub?.close();
super.dispose(); super.dispose();
} }
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
} }
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000'; return 'https://reader-api.fevirtus.dev';
} }
return 'http://localhost:8000'; return 'http://localhost:8000';
+16 -1
View File
@@ -39,13 +39,28 @@ class BookmarkModel extends Equatable {
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel( factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String, id: json['id'] as String,
novelId: json['novelId'] as String, novelId: json['novelId'] as String,
type: BookmarkType.fromString(json['type'] as String?),
lastChapterId: json['lastChapterId'] as String?, lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?, lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?) readChapters: (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList() ?? .toList() ??
[], [],
type: () {
final explicitType = BookmarkType.fromString(json['type'] as String?);
if ((json['type'] as String?) != null) {
return explicitType;
}
// Backward-compatible inference when API does not return `type`.
final inferredLastChapter = json['lastChapterNumber'] as int?;
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const <int>[];
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
? BookmarkType.reading
: BookmarkType.bookmarked;
}(),
novel: json['novel'] != null novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>) ? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null, : null,
+22
View File
@@ -16,6 +16,7 @@ class LocalStore {
static const _kProgressChapterId = 'progress_chapter_id_'; static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_'; static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_'; static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ────────────────────────────────────────────────────── // ── Reading settings ──────────────────────────────────────────────────────
@@ -86,6 +87,27 @@ class LocalStore {
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0, 'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
}; };
} }
// ── Last route restore (cold start after process reclaim) ───────────────
Future<void> saveLastRoutePath(String path) async {
final normalized = path.trim();
if (normalized.isEmpty || normalized == '/') return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastRoutePath, normalized);
}
Future<String?> loadLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_kLastRoutePath)?.trim();
if (value == null || value.isEmpty) return null;
return value;
}
Future<void> clearLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastRoutePath);
}
} }
final localStoreProvider = Provider<LocalStore>((_) => LocalStore()); final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
} }
} }
void syncProgress({
required String novelId,
required String chapterId,
required int chapterNumber,
Map<String, dynamic>? serverBookmark,
}) {
final current = state.valueOrNull ?? const <BookmarkModel>[];
BookmarkModel? parsedFromServer;
if (serverBookmark != null) {
try {
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
} catch (_) {
parsedFromServer = null;
}
}
final index = current.indexWhere((b) => b.novelId == novelId);
if (index >= 0) {
final existing = current[index];
final merged = parsedFromServer ?? BookmarkModel(
id: existing.id,
novelId: existing.novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: {
...existing.readChapters,
chapterNumber,
}.toList()
..sort(),
novel: existing.novel,
);
final updated = [...current]..[index] = merged;
state = AsyncValue.data(updated);
return;
}
if (parsedFromServer != null) {
state = AsyncValue.data([parsedFromServer, ...current]);
return;
}
// Fallback when API response doesn't include bookmark object.
final synthetic = BookmarkModel(
id: 'progress-$novelId',
novelId: novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: [chapterNumber],
);
state = AsyncValue.data([synthetic, ...current]);
}
Future<void> toggle(String novelId) async { Future<void> toggle(String novelId) async {
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
@@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/config/app_config.dart';
import '../../../core/models/chapter_model.dart'; import '../../../core/models/chapter_model.dart';
import '../../../core/models/reading_settings.dart'; import '../../../core/models/reading_settings.dart';
import '../../../core/storage/local_store.dart'; import '../../../core/storage/local_store.dart';
@@ -97,38 +98,64 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required int highlightStart, required int highlightStart,
required int highlightEnd, required int highlightEnd,
required Function(int charOffset) onSentenceTap, required Function(int charOffset) onSentenceTap,
// When true, renders Text.rich instead of SelectableText.rich.
// This avoids the "selection.isValid" assertion that fires when a
// TapGestureRecognizer on a span triggers TTS/scroll while SelectableText
// still holds a stale internal selection.
bool useTapRecognizer = false,
}) { }) {
if (sentenceSlices.isEmpty) { final spans = sentenceSlices.map((slice) {
return SelectableText( final start = slice.start;
'', final end = slice.end;
textAlign: textAlign,
style: style, final isCurrentSpoken = isActiveParagraph &&
onTap: () => onSentenceTap(0), highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
if (!useTapRecognizer) {
return TextSpan(
text: slice.text,
style: isCurrentSpoken ? highlightStyle : null,
);
}
// Use WidgetSpan + GestureDetector to avoid lifecycle issues from
// creating/discarding many TapGestureRecognizer instances across rebuilds.
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
// Unfocus immediately so SelectableText drops its selection state
// before any scroll notification can call getBoxesForSelection.
FocusManager.instance.primaryFocus?.unfocus();
onSentenceTap(start);
},
child: Text(
slice.text,
style: isCurrentSpoken ? highlightStyle : style,
),
),
);
}).toList();
final textSpan = TextSpan(style: style, children: spans);
if (useTapRecognizer || sentenceSlices.isEmpty) {
// Use plain Text.rich when sentence-tap TTS is active.
// SelectableText keeps an internal TextEditingController/selection that
// becomes invalid after a programmatic rebuild, causing a Flutter
// assertion failure in getBoxesForSelection during scroll.
return GestureDetector(
onTap: sentenceSlices.isEmpty ? () => onSentenceTap(0) : null,
child: RichText(text: textSpan, textAlign: textAlign),
); );
} }
return SelectableText.rich( return SelectableText.rich(textSpan, textAlign: textAlign);
TextSpan(
style: style,
children: sentenceSlices.map((slice) {
final start = slice.start;
final end = slice.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
return TextSpan(
text: slice.text,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
),
textAlign: textAlign,
);
} }
List<List<_SentenceSlice>> _sentenceSlicesForChapter( List<List<_SentenceSlice>> _sentenceSlicesForChapter(
@@ -250,7 +277,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final chapter = chapterAsync.valueOrNull; final chapter = chapterAsync.valueOrNull;
if (chapter == null) return; if (chapter == null) return;
if (previous.contentKey == chapter.id &&
next.contentKey != null &&
next.contentKey != chapter.id &&
next.contentKey != previous.contentKey &&
(next.status == TtsStatus.playing || next.status == TtsStatus.paused)) {
final targetChapterId = next.contentKey!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
});
return;
}
// Chapter-completion → auto-advance to next chapter. // Chapter-completion → auto-advance to next chapter.
// On Android, native service already fetches and starts next chapter.
// Re-queueing auto-start from UI causes duplicate START_READING races.
if (defaultTargetPlatform == TargetPlatform.android) {
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
}
return;
}
if (next.completedCount > _lastTtsCompletedCount) { if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount; _lastTtsCompletedCount = next.completedCount;
if (next.contentKey == chapter.id && chapter.nextChapterId != null) { if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
@@ -276,6 +325,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content, chapter.content,
contentKey: chapter.id, contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}', title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
); );
_autoStartQueuedChapterId = null; _autoStartQueuedChapterId = null;
}); });
@@ -415,9 +467,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String targetChapterId, String targetChapterId,
) { ) {
final tts = ref.read(ttsProvider); final tts = ref.read(ttsProvider);
final isCurrentlyReading = tts.contentKey == currentChapterId && // Only auto-start on the target chapter when TTS is actively PLAYING.
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); // If paused, the user intentionally stopped do not resume on navigation.
if (!isCurrentlyReading) return; final isActivelyPlaying = tts.contentKey == currentChapterId &&
tts.status == TtsStatus.playing;
if (!isActivelyPlaying) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId); ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
} }
@@ -426,6 +480,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (tts.pendingAutoStartChapterId != chapter.id) return; if (tts.pendingAutoStartChapterId != chapter.id) return;
if (_autoStartQueuedChapterId == chapter.id) return; if (_autoStartQueuedChapterId == chapter.id) return;
// If native TTS service already moved to this chapter and is actively
// controlling playback, do not issue another manual START_READING.
final isAlreadyPlayingThisChapter =
tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (isAlreadyPlayingThisChapter) {
ref.read(ttsProvider.notifier).clearPendingAutoStartChapter();
return;
}
_autoStartQueuedChapterId = chapter.id; _autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
@@ -435,6 +499,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content, chapter.content,
contentKey: chapter.id, contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}', title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
); );
_autoStartQueuedChapterId = null; _autoStartQueuedChapterId = null;
}); });
@@ -549,6 +616,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String previewContent, String previewContent,
String chapterId, String chapterId,
String chapterTitle, String chapterTitle,
String? nextChapterId,
int? chapterNumber,
) async { ) async {
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
@@ -1019,6 +1088,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: previewContent, content: previewContent,
contentKey: chapterId, contentKey: chapterId,
title: 'Chương $chapterTitle', title: 'Chương $chapterTitle',
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: AppConfig.baseUrl,
includeTitleOnStart: false, includeTitleOnStart: false,
resolveStartParagraphIndex: resolveStartParagraphIndex:
_firstVisibleParagraphIndex, _firstVisibleParagraphIndex,
@@ -1117,6 +1189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content, chapter.content,
chapter.id, chapter.id,
'Chương ${chapter.number}: ${chapter.title}', 'Chương ${chapter.number}: ${chapter.title}',
chapter.nextChapterId,
chapter.number,
), ),
barBackgroundColor: readerBackground, barBackgroundColor: readerBackground,
foregroundColor: readerTextColor, foregroundColor: readerTextColor,
@@ -1255,14 +1329,27 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!canStartFromSentence) { if (!canStartFromSentence) {
return; return;
} }
// Synchronous unfocus clears stale SelectableText selection
// before startReading triggers a widget rebuild + scroll.
FocusManager.instance.primaryFocus?.unfocus();
ref
.read(ttsProvider.notifier)
.clearPendingAutoStartChapter();
ref.read(ttsProvider.notifier).startReading( ref.read(ttsProvider.notifier).startReading(
chapter.content, chapter.content,
contentKey: chapter.id, contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}', title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
startParagraphIndex: index, startParagraphIndex: index,
startCharOffset: charOffset, startCharOffset: charOffset,
); );
}, },
useTapRecognizer: settings.enableSentenceTapTts ||
(tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing ||
tts.status == TtsStatus.paused)),
), ),
), ),
), ),
@@ -1357,6 +1444,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: chapter.content, content: chapter.content,
contentKey: chapter.id, contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}', title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
), ),
), ),
); );
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
required this.content, required this.content,
this.contentKey, this.contentKey,
this.title, this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true, this.includeTitleOnStart = true,
this.resolveStartParagraphIndex, this.resolveStartParagraphIndex,
this.onStarted, this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content; final String content;
final String? contentKey; final String? contentKey;
final String? title; final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart; final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex; final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted; final VoidCallback? onStarted;
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return; return;
} }
notifier.clearPendingAutoStartChapter();
unawaited( unawaited(
notifier.startReading( notifier.startReading(
content, content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(), startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey, contentKey: contentKey,
title: title, title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart, includeTitle: includeTitleOnStart,
), ),
); );
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart'; import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart'; import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// ─── Chapter content ───────────────────────────────────────────────────────── // ─── Chapter content ─────────────────────────────────────────────────────────
@@ -94,12 +95,28 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget) // Also notify server (fire and forget)
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: { final res = await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId, 'novelId': _novelId,
'chapterId': chapterId, 'chapterId': chapterId,
'chapterNumber': chapterNumber, 'chapterNumber': chapterNumber,
'progress': offset, 'progress': offset,
}); });
final data = res.data;
Map<String, dynamic>? bookmarkJson;
if (data is Map<String, dynamic>) {
final bookmark = data['bookmark'];
if (bookmark is Map<String, dynamic>) {
bookmarkJson = bookmark;
}
}
_ref.read(bookshelfProvider.notifier).syncProgress(
novelId: _novelId!,
chapterId: chapterId,
chapterNumber: chapterNumber,
serverBookmark: bookmarkJson,
);
} catch (_) {} } catch (_) {}
} }
+14 -1
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused } enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.9; const double kTtsBaseSpeechRate = 0.9;
@@ -635,12 +637,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
int? startCharOffset, int? startCharOffset,
String? contentKey, String? contentKey,
String? title, String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true, bool includeTitle = true,
}) async { }) async {
if (!_initialized) { if (!_initialized) {
await (_initFuture ?? _init()); await (_initFuture ?? _init());
} }
// A direct start request (tap sentence/play button) should win over any
// queued chapter auto-start from previous navigation/completion events.
state = state.copyWith(clearPendingAutoStartChapterId: true);
_segments = _buildSegments( _segments = _buildSegments(
content, content,
title: title, title: title,
@@ -685,14 +694,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
try { try {
await _mediaChannel.invokeMethod<void>('startReading', { await _mediaChannel.invokeMethod<void>('startReading', {
'content': content,
'contentKey': contentKey, 'contentKey': contentKey,
'title': title, 'title': title,
'nextChapterId': nextChapterId,
'chapterNumber': chapterNumber,
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
'startIndex': validIndex, 'startIndex': validIndex,
'speed': state.speed, 'speed': state.speed,
'language': state.language, 'language': state.language,
'voiceName': state.voiceName, 'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled, 'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(), 'includeTitle': includeTitle,
}); });
} on PlatformException { } on PlatformException {
await _startFallbackReading( await _startFallbackReading(
@@ -1,25 +1,48 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/storage/local_store.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@override @override
State<SplashScreen> createState() => _SplashScreenState(); ConsumerState<SplashScreen> createState() => _SplashScreenState();
} }
class _SplashScreenState extends State<SplashScreen> { class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _redirectTimer; Timer? _redirectTimer;
bool _isRestorableRoute(String path) {
if (path.isEmpty || path == RouteNames.splash) return false;
return path == RouteNames.home ||
path == RouteNames.login ||
path == RouteNames.search ||
path.startsWith('${RouteNames.search}?') ||
path == RouteNames.genres ||
path == RouteNames.bookshelf ||
path == RouteNames.profile ||
path == RouteNames.settings ||
path.startsWith('/novel/') ||
path.startsWith('/reader/') ||
path.startsWith('/comments/');
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () { _redirectTimer = Timer(const Duration(milliseconds: 700), () async {
if (!mounted) return; if (!mounted) return;
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
if (!mounted) return;
if (lastPath != null && _isRestorableRoute(lastPath)) {
context.go(lastPath);
return;
}
context.go(RouteNames.home); context.go(RouteNames.home);
}); });
} }