2 Commits

Author SHA1 Message Date
virtus d4c6cdb013 feat: Add native TTS snapshot reconciliation and lifecycle management
Build Android AAB / build-aab (push) Successful in 12m11s
Build Android APK / build-apk (push) Successful in 14m12s
2026-04-27 00:58:51 +07:00
virtus c3e6d66f43 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.
2026-04-27 00:48:05 +07:00
14 changed files with 805 additions and 129 deletions
+8
View File
@@ -115,3 +115,11 @@ Optional (iOS/web):
```bash
--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 com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
import com.example.reader_app.tts.ReaderTtsStartRequest
class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background"
@@ -53,23 +53,34 @@ class MainActivity : FlutterActivity() {
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0
val content = call.argument<String>("content") ?: ""
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
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(
this,
parseSegments(call.argument<List<*>>("segments")),
startIndex,
contentKey,
title,
speed,
language,
voiceName,
backgroundModeEnabled,
ReaderTtsStartRequest(
content = content,
contentKey = contentKey,
title = title,
speed = speed,
language = language,
voiceName = voiceName,
backgroundModeEnabled = backgroundModeEnabled,
nextChapterId = nextChapterId,
chapterNumber = chapterNumber,
includeTitle = includeTitle,
apiBaseUrl = apiBaseUrl,
startIndex = startIndex,
),
)
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 {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -16,7 +16,6 @@ import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Parcelable
import android.os.PowerManager
import android.speech.tts.TextToSpeech
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.PlaybackStateCompat
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 java.util.Locale
import java.util.concurrent.Executors
@Parcelize
data class ReaderTtsSegment(
val text: String,
val paragraphIndex: Int,
val start: 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 {
companion object {
@@ -51,6 +60,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
private const val START_GRACE_PERIOD_MS = 5_000L
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_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_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
const val EXTRA_SEGMENTS = "segments"
const val EXTRA_START_INDEX = "startIndex"
const val EXTRA_SESSION_TOKEN = "sessionToken"
const val EXTRA_CONTENT_KEY = "contentKey"
const val EXTRA_TITLE = "title"
const val EXTRA_SPEED = "speed"
@@ -84,30 +93,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
)
}
fun startReading(
context: Context,
segments: ArrayList<ReaderTtsSegment>,
startIndex: Int,
contentKey: String?,
title: String?,
speed: Double,
language: String,
voiceName: String?,
backgroundModeEnabled: Boolean,
): Boolean {
fun startReading(context: Context, request: ReaderTtsStartRequest): Boolean {
return try {
val sessionToken = ReaderTtsPlaybackStore.enqueue(request)
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)
putExtra(EXTRA_SESSION_TOKEN, sessionToken)
},
)
true
@@ -201,7 +194,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null
private var pausedByAudioFocus = false
private var isDuckedByAudioFocus = false
private var volumeMultiplier = 1.0f
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 {
override fun run() {
runPlaybackHealthCheck()
@@ -214,15 +217,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
clearDuckingState(restartPlayback = false)
if (status == "playing") {
pausedByAudioFocus = true
handlePause()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> handleDuckAudioFocusLoss()
AudioManager.AUDIOFOCUS_GAIN -> {
val shouldRestorePlaybackVolume = isDuckedByAudioFocus
clearDuckingState(
restartPlayback = shouldRestorePlaybackVolume && status == "playing",
)
if (pausedByAudioFocus && status == "paused") {
pausedByAudioFocus = false
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
createNotificationChannel()
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()
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
publishSnapshot()
@@ -264,13 +284,17 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
ACTION_SKIP_BACK -> handleSkip(-1)
ACTION_SET_SPEED -> {
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot()
}
ACTION_SET_VOICE -> {
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot()
}
ACTION_SET_BACKGROUND_MODE -> {
@@ -411,21 +435,53 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
}
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()
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
backgroundModeEnabled = request.backgroundModeEnabled
speed = request.speed
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)
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))
currentIndex = request.startIndex.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
sessionGeneration += 1
clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "playing"
pausedByAudioFocus = false
pendingReplayAfterInit = false
@@ -433,6 +489,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
syncPowerState()
publishSnapshot()
if (segments.isEmpty()) {
handleStop(clearContentKey = false, reason = "empty_segments")
return
}
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
@@ -442,6 +503,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "paused"
isPreparingNextChapter = false
pendingReplayAfterInit = false
tts?.stop()
syncPowerState()
@@ -453,6 +515,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (segments.isEmpty()) return
cancelIdleStop()
status = "playing"
isPreparingNextChapter = false
sessionGeneration += 1
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
@@ -468,10 +531,15 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
clearScheduledRecoveries()
cancelIdleStop()
clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "idle"
currentIndex = 0
segments = emptyList()
title = null
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
if (clearContentKey) {
contentKey = null
}
@@ -490,6 +558,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
currentIndex = nextIndex
sessionGeneration += 1
clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing"
pendingReplayAfterInit = false
tts?.stop()
@@ -505,16 +574,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) {
status = "idle"
currentIndex = 0
completedCount += 1
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
clearUtteranceRuntimeState()
abandonAudioFocus()
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleIdleStop()
handleChapterCompleted()
return
}
@@ -542,6 +602,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return
isPreparingNextChapter = false
if (!requestAudioFocus()) {
pausedByAudioFocus = true
status = "paused"
@@ -576,10 +637,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
scheduleUtteranceWatchdog(utteranceId)
val speakResult = try {
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 {
@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) {
Log.e(TAG, "speak() failed for index=$currentIndex", e)
@@ -761,10 +832,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
.build(),
)
.setAcceptsDelayedFocusGain(true)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
.also { audioFocusRequest = it }
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
} else {
@Suppress("DEPRECATION")
@@ -846,7 +920,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (wakeLock?.isHeld == true) return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"reader_app:ReaderTtsPlayback"
"$packageName:ReaderTtsPlayback"
).apply {
setReferenceCounted(false)
acquire()
@@ -875,6 +949,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
private fun currentProgressLabel(): String {
if (isPreparingNextChapter) return "Đang tải chương tiếp theo"
if (segments.isEmpty()) return voiceName ?: language
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")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
@@ -1071,6 +1156,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"contentKey" to contentKey,
"completedCount" to completedCount,
"backgroundModeEnabled" to backgroundModeEnabled,
"isPreparingNextChapter" to isPreparingNextChapter,
"language" to language,
"voiceName" to voiceName,
"availableVietnameseVoices" to availableVoices,
@@ -1084,23 +1170,254 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val channel = NotificationChannel(
CHANNEL_ID,
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 {
description = "Điều khiển đọc truyện bằng TTS"
setShowBadge(false)
setSound(null, null)
enableLights(false)
enableVibration(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())
private fun sanitizeForTts(raw: String): String {
if (raw.isBlank()) return raw
return raw
.replace(Regex("[\"“”]"), " ")
.replace(Regex("[_\\$#^*+=~`|<>\\\\\\[\\]{}]"), " ")
.replace(Regex("\\s+"), " ")
.trim()
}
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() {
@@ -1111,8 +1428,13 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
status = "idle"
currentIndex = 0
segments = emptyList()
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
isPreparingNextChapter = false
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
clearDuckingState(restartPlayback = false)
publishSnapshot()
tts?.stop()
tts?.shutdown()
@@ -1123,6 +1445,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
isForegroundActive = false
}
mediaSession.release()
networkExecutor.shutdownNow()
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/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart';
@@ -19,10 +23,23 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
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
void initState() {
super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
@@ -92,6 +109,7 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
@override
void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close();
super.dispose();
}
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000';
return 'https://reader-api.fevirtus.dev';
}
return 'http://localhost:8000';
+16 -1
View File
@@ -39,13 +39,28 @@ class BookmarkModel extends Equatable {
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
type: BookmarkType.fromString(json['type'] as String?),
lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.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
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null,
+22
View File
@@ -16,6 +16,7 @@ class LocalStore {
static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ──────────────────────────────────────────────────────
@@ -86,6 +87,27 @@ class LocalStore {
'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());
@@ -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 {
try {
final client = _ref.read(apiClientProvider);
@@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.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 '../../../app/router/route_names.dart';
import '../../../core/config/app_config.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/models/reading_settings.dart';
import '../../../core/storage/local_store.dart';
@@ -25,7 +26,8 @@ class ReaderScreen extends ConsumerStatefulWidget {
ConsumerState<ReaderScreen> createState() => _ReaderScreenState();
}
class _ReaderScreenState extends ConsumerState<ReaderScreen> {
class _ReaderScreenState extends ConsumerState<ReaderScreen>
with WidgetsBindingObserver {
static const List<Color> _backgroundColorChoices = [
Color(0xFFFFFEF8),
Color(0xFFF6EAD7),
@@ -97,20 +99,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required int highlightStart,
required int highlightEnd,
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) {
return SelectableText(
'',
textAlign: textAlign,
style: style,
onTap: () => onSentenceTap(0),
);
}
return SelectableText.rich(
TextSpan(
style: style,
children: sentenceSlices.map((slice) {
final spans = sentenceSlices.map((slice) {
final start = slice.start;
final end = slice.end;
@@ -120,15 +115,48 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
start >= highlightStart &&
end <= highlightEnd;
if (!useTapRecognizer) {
return TextSpan(
text: slice.text,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
}
// 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,
),
),
textAlign: textAlign,
);
}).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(textSpan, textAlign: textAlign);
}
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
@@ -233,11 +261,39 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
return partiallyVisibleIndex ?? 0;
}
Future<void> _reconcileChapterWithNativeTts() async {
if (defaultTargetPlatform != TargetPlatform.android) return;
final notifier = ref.read(ttsProvider.notifier);
await notifier.refreshNativeSnapshot();
if (!mounted) return;
final tts = ref.read(ttsProvider);
final targetChapterId = tts.contentKey;
if (targetChapterId == null || targetChapterId.isEmpty) return;
if (targetChapterId == widget.chapterId) return;
if (tts.status != TtsStatus.playing && tts.status != TtsStatus.paused) return;
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_scrollCtrl.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_reconcileChapterWithNativeTts());
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_reconcileChapterWithNativeTts());
}
}
/// Handle TTS state transitions that require navigation or restarts.
@@ -250,7 +306,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final chapter = chapterAsync.valueOrNull;
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.
// 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) {
_lastTtsCompletedCount = next.completedCount;
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
@@ -276,6 +354,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
);
_autoStartQueuedChapterId = null;
});
@@ -284,6 +365,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_uiAutoHideTimer?.cancel();
_scrollCtrl.removeListener(_onScroll);
_scrollCtrl.dispose();
@@ -415,9 +497,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String targetChapterId,
) {
final tts = ref.read(ttsProvider);
final isCurrentlyReading = tts.contentKey == currentChapterId &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (!isCurrentlyReading) return;
// Only auto-start on the target chapter when TTS is actively PLAYING.
// If paused, the user intentionally stopped do not resume on navigation.
final isActivelyPlaying = tts.contentKey == currentChapterId &&
tts.status == TtsStatus.playing;
if (!isActivelyPlaying) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
}
@@ -426,6 +510,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (tts.pendingAutoStartChapterId != 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;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -435,6 +529,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
);
_autoStartQueuedChapterId = null;
});
@@ -549,6 +646,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String previewContent,
String chapterId,
String chapterTitle,
String? nextChapterId,
int? chapterNumber,
) async {
await showModalBottomSheet<void>(
context: context,
@@ -1019,6 +1118,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: previewContent,
contentKey: chapterId,
title: 'Chương $chapterTitle',
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: AppConfig.baseUrl,
includeTitleOnStart: false,
resolveStartParagraphIndex:
_firstVisibleParagraphIndex,
@@ -1117,6 +1219,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
chapter.id,
'Chương ${chapter.number}: ${chapter.title}',
chapter.nextChapterId,
chapter.number,
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
@@ -1255,14 +1359,27 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!canStartFromSentence) {
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(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
useTapRecognizer: settings.enableSentenceTapTts ||
(tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing ||
tts.status == TtsStatus.paused)),
),
),
),
@@ -1357,6 +1474,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: chapter.content,
contentKey: chapter.id,
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,
this.contentKey,
this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true,
this.resolveStartParagraphIndex,
this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content;
final String? contentKey;
final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted;
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return;
}
notifier.clearPendingAutoStartChapter();
unawaited(
notifier.startReading(
content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey,
title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart,
),
);
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// ─── Chapter content ─────────────────────────────────────────────────────────
@@ -94,12 +95,28 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget)
try {
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,
'chapterId': chapterId,
'chapterNumber': chapterNumber,
'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 (_) {}
}
+30 -1
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.9;
@@ -259,6 +261,22 @@ class TtsNotifier extends StateNotifier<TtsState> {
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
}
Future<void> refreshNativeSnapshot() async {
if (!_useNativeAndroidMediaService) return;
if (!_initialized) {
await (_initFuture ?? _init());
return;
}
try {
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
} catch (_) {
// Ignore snapshot pull errors; event stream updates will continue.
}
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices;
@@ -635,12 +653,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
int? startCharOffset,
String? contentKey,
String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true,
}) async {
if (!_initialized) {
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(
content,
title: title,
@@ -685,14 +710,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
try {
await _mediaChannel.invokeMethod<void>('startReading', {
'content': content,
'contentKey': contentKey,
'title': title,
'nextChapterId': nextChapterId,
'chapterNumber': chapterNumber,
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
'includeTitle': includeTitle,
});
} on PlatformException {
await _startFallbackReading(
@@ -1,25 +1,48 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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});
@override
State<SplashScreen> createState() => _SplashScreenState();
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
class _SplashScreenState extends ConsumerState<SplashScreen> {
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
void initState() {
super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
_redirectTimer = Timer(const Duration(milliseconds: 700), () async {
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);
});
}