2 Commits

Author SHA1 Message Date
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
virtus 66613857e8 Refactor chapter list provider and improve TTS functionality
Build Android APK / build-apk (push) Successful in 12m10s
Build Android AAB / build-aab (push) Successful in 19m35s
- Removed the constant chapterPageSize and refactored ChapterListQuery to use a simpler approach for fetching chapters.
- Updated the chapter list provider to handle fetching all chapters in a single request with pagination.
- Enhanced error handling for fetching chapters by resolving canonical IDs when necessary.
- Modified TTS functionality to ensure proper handling of Android fallback reading and improved error management.
- Added a new setting to enable/disable TTS on sentence tap.
- Updated UI components in the reader screen for better user experience and added navigation buttons for chapters.
- Bumped version to 1.0.3+4 in pubspec.yaml.
2026-04-24 03:03:32 +07:00
19 changed files with 1859 additions and 564 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
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
@@ -15,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
@@ -28,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 {
@@ -50,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"
@@ -62,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"
@@ -83,31 +93,21 @@ 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,
) {
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
} catch (e: Throwable) {
Log.e(TAG, "startForegroundService blocked or failed", e)
false
}
}
fun pause(context: Context) =
@@ -194,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()
@@ -207,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)
}
}
}
@@ -229,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()
@@ -257,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 -> {
@@ -404,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
@@ -426,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)
}
@@ -435,6 +503,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "paused"
isPreparingNextChapter = false
pendingReplayAfterInit = false
tts?.stop()
syncPowerState()
@@ -446,6 +515,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (segments.isEmpty()) return
cancelIdleStop()
status = "playing"
isPreparingNextChapter = false
sessionGeneration += 1
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
@@ -461,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
}
@@ -483,6 +558,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
currentIndex = nextIndex
sessionGeneration += 1
clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing"
pendingReplayAfterInit = false
tts?.stop()
@@ -498,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
}
@@ -535,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"
@@ -569,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)
@@ -754,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")
@@ -839,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()
@@ -868,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}"
}
@@ -903,9 +985,20 @@ 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)
.setSmallIcon(R.mipmap.ic_launcher)
// Avoid adaptive launcher icon for foreground notifications on strict OEM ROMs.
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent())
@@ -995,8 +1088,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
// to prevent Android from killing us.
if (status == "playing" || status == "paused") {
val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
isForegroundActive = startForegroundCompat(notification)
}
return
}
@@ -1005,8 +1097,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"playing", "paused" -> {
val notification = buildNotification()
if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
isForegroundActive = startForegroundCompat(notification)
} else {
notificationManager.notify(NOTIFICATION_ID, notification)
}
@@ -1021,6 +1112,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
}
}
private fun startForegroundCompat(notification: android.app.Notification): Boolean {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (e: Throwable) {
Log.e(TAG, "startForeground failed", e)
false
}
}
private fun publishSnapshot() {
val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
@@ -1047,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,
@@ -1060,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() {
@@ -1087,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()
@@ -1099,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';
+34 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.dart';
enum BookmarkType {
reading('reading'),
bookmarked('bookmarked');
const BookmarkType(this.value);
final String value;
static BookmarkType fromString(String? str) {
return values.firstWhere(
(e) => e.value == str,
orElse: () => BookmarkType.bookmarked,
);
}
}
class BookmarkModel extends Equatable {
const BookmarkModel({
required this.id,
required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId,
this.lastChapterNumber,
this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id;
final String novelId;
final BookmarkType type;
final String? lastChapterId;
final int? lastChapterNumber;
final List<int> readChapters;
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
?.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,
);
@override
List<Object?> get props => [id, novelId];
List<Object?> get props => [id, novelId, type];
}
+6
View File
@@ -10,6 +10,7 @@ class ReadingSettings {
this.horizontalPadding = 20,
this.paragraphSpacing = 24,
this.textAlign = 'left',
this.enableSentenceTapTts = false,
});
final double fontSize;
@@ -22,6 +23,7 @@ class ReadingSettings {
final double horizontalPadding;
final double paragraphSpacing;
final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({
double? fontSize,
@@ -34,6 +36,7 @@ class ReadingSettings {
double? horizontalPadding,
double? paragraphSpacing,
String? textAlign,
bool? enableSentenceTapTts,
}) =>
ReadingSettings(
fontSize: fontSize ?? this.fontSize,
@@ -46,6 +49,7 @@ class ReadingSettings {
horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
);
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -60,6 +64,7 @@ class ReadingSettings {
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {
@@ -73,5 +78,6 @@ class ReadingSettings {
'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
};
}
+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());
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../novel/providers/novels_provider.dart';
import '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart';
@@ -50,7 +51,7 @@ class BookshelfScreen extends ConsumerWidget {
return Scaffold(
body: DefaultTabController(
length: 3,
length: 2,
child: Column(
children: [
MainAppHeader(
@@ -68,9 +69,8 @@ class BookshelfScreen extends ConsumerWidget {
unselectedLabelColor: Colors.white70,
dividerColor: Colors.transparent,
tabs: const [
Tab(text: 'Đã đọc'),
Tab(text: 'Đã lưu'),
Tab(text: 'Đang mở'),
Tab(text: 'Đang đọc'),
Tab(text: 'Đánh dấu'),
],
),
),
@@ -93,23 +93,18 @@ class BookshelfScreen extends ConsumerWidget {
),
),
data: (bookmarks) {
final readItems = bookmarks.where((e) => e.readChapters.isNotEmpty).toList();
final savedItems = bookmarks;
final openingItems = bookmarks.where((e) => e.lastChapterId != null).toList();
final readingItems = ref.watch(readingBookmarksProvider);
final bookmarkedItems = ref.watch(savedBookmarksProvider);
return TabBarView(
children: [
_BookshelfList(
bookmarks: readItems,
emptyLabel: 'Chưa có truyện đã đọc.',
bookmarks: readingItems,
emptyLabel: 'Chưa có truyện đang đọc.',
),
_BookshelfList(
bookmarks: savedItems,
emptyLabel: 'Chưa có truyện nào trong tủ sách.',
),
_BookshelfList(
bookmarks: openingItems,
emptyLabel: 'Chưa có truyện đang mở.',
bookmarks: bookmarkedItems,
emptyLabel: 'Chưa có truyện đánh dấu.',
),
],
);
@@ -150,20 +145,57 @@ class _BookshelfList extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) => _BookmarkTile(bookmark: bookmarks[index]),
itemBuilder: (context, index) {
final bookmark = bookmarks[index];
return _BookmarkTile(
bookmark: bookmark,
onRemove: () => ref
.read(bookshelfProvider.notifier)
.removeFromShelf(bookmark.novelId, bookmark.type),
);
},
),
);
}
}
class _BookmarkTile extends StatelessWidget {
class _BookmarkTile extends ConsumerWidget {
final BookmarkModel bookmark;
const _BookmarkTile({required this.bookmark});
final VoidCallback onRemove;
const _BookmarkTile({
required this.bookmark,
required this.onRemove,
});
Future<void> _openContinueReader(BuildContext context, WidgetRef ref) async {
var targetChapterId = bookmark.lastChapterId;
if (targetChapterId == null || targetChapterId.isEmpty) {
try {
final chapters = await ref.read(
chapterListProvider(bookmark.novelId).future,
);
if (chapters.isNotEmpty) {
targetChapterId = chapters.first.id;
}
} catch (_) {
// Fall through to novel detail when chapter lookup fails.
}
}
if (!context.mounted) return;
if (targetChapterId != null && targetChapterId.isNotEmpty) {
context.push(RouteNames.readerChapter(targetChapterId));
return;
}
context.push(RouteNames.novelDetail(bookmark.novelId));
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel;
return Container(
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
@@ -211,7 +243,10 @@ class _BookmarkTile extends StatelessWidget {
),
),
const SizedBox(width: 8),
const Icon(Icons.close_rounded, size: 20),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close_rounded, size: 20),
),
],
),
const SizedBox(height: 8),
@@ -249,7 +284,7 @@ class _BookmarkTile extends StatelessWidget {
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
onPressed: () => _openContinueReader(context, ref),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc tiếp'),
style: FilledButton.styleFrom(
@@ -258,22 +293,11 @@ class _BookmarkTile extends StatelessWidget {
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
icon: const Icon(Icons.headphones_rounded),
label: const Text('Nghe tiếp'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
),
],
),
],
),
),
);
}
}
@@ -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);
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == novelId);
}
Future<void> removeFromShelf(String novelId, BookmarkType type) async {
try {
final client = _ref.read(apiClientProvider);
await client.dio.delete(
'/api/user/bookmarks/$novelId',
queryParameters: {'type': type.value},
);
final current = state.valueOrNull ?? [];
state = AsyncValue.data(
current.where((b) => b.novelId != novelId || b.type != type).toList(),
);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
final bookshelfProvider =
@@ -51,6 +123,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref);
});
final readingBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.reading).toList();
});
final savedBookmarksProvider = Provider<List<BookmarkModel>>((ref) {
final bookmarks = ref.watch(bookshelfProvider).valueOrNull ?? [];
return bookmarks.where((b) => b.type == BookmarkType.bookmarked).toList();
});
final isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,6 @@ import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams {
@@ -28,11 +26,11 @@ class BrowseParams {
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'Đang ra';
return 'ONGOING';
case 'completed':
return 'Hoàn thành';
return 'COMPLETED';
case 'hiatus':
return 'Tạm ngưng';
return 'HIATUS';
default:
return raw;
}
@@ -189,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ─────────────────────────────────────────────────────────────
class ChapterListQuery {
const ChapterListQuery({required this.novelId, this.page = 1});
final String novelId;
final int page;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ChapterListQuery &&
other.novelId == novelId &&
other.page == page;
}
@override
int get hashCode => Object.hash(novelId, page);
}
class ChapterListPage {
const ChapterListPage({
required this.chapters,
required this.totalChapters,
required this.totalPages,
required this.currentPage,
});
final List<ChapterListItem> chapters;
final int totalChapters;
final int totalPages;
final int currentPage;
}
final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
const limit = 500;
var page = 1;
var totalPages = 1;
final items = <ChapterListItem>[];
while (page <= totalPages) {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {
'page': query.page,
'limit': chapterPageSize,
},
queryParameters: {'page': page, 'limit': limit},
);
return res.data as Map<String, dynamic>;
final data = res.data as Map<String, dynamic>;
final chapters = data['chapters'] as List? ?? const [];
items.addAll(
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)),
);
final apiTotalPages = (data['totalPages'] as num?)?.toInt() ?? 1;
totalPages = apiTotalPages > 0 ? apiTotalPages : 1;
page += 1;
}
var data = await fetchChapterPage(query.novelId);
var chapters = data['chapters'] as List? ?? const [];
return items;
}
try {
return await fetchAllChapters(novelId);
} catch (_) {
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
// first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try {
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
final novelRes = await client.dio.get('/api/novels/$novelId');
final novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
data = await fetchChapterPage(canonicalId);
chapters = data['chapters'] as List? ?? const [];
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
return await fetchAllChapters(canonicalId);
}
} catch (_) {
// Keep original empty list when fallback resolution fails.
}
rethrow;
}
return ChapterListPage(
chapters:
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
);
});
@@ -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';
@@ -97,20 +98,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 +114,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(
@@ -179,7 +206,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
if (tts.status == TtsStatus.idle) return;
if (tts.status != TtsStatus.playing) return;
final index = tts.activeParagraphIndex;
if (index < 0 || index >= paragraphCount) return;
if (index == _lastAutoScrolledParagraph) return;
@@ -189,6 +216,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!mounted) return;
final ctx = _paragraphKeys[index].currentContext;
if (ctx == null) return;
// Clear any active text-selection focus before programmatic scrolling.
FocusManager.instance.primaryFocus?.unfocus();
Scrollable.ensureVisible(
ctx,
alignment: 0.22,
@@ -248,7 +277,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) {
@@ -274,6 +325,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;
});
@@ -413,9 +467,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);
}
@@ -424,6 +480,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;
@@ -433,6 +499,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;
});
@@ -455,11 +524,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
builder: (sheetContext) {
return Consumer(
builder: (context, ref, _) {
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
final chaptersAsync = ref.watch(
chapterListProvider(
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
),
chapterListProvider(currentChapter.novelId),
);
return FractionallySizedBox(
heightFactor: 0.82,
@@ -489,12 +555,20 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: chaptersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
data: (pageData) {
final chapters = pageData.chapters;
data: (chapters) {
if (chapters.isEmpty) {
return const Center(child: Text('Chưa có danh sách chương.'));
}
// Find index of current chapter for auto-scroll
final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id);
final scrollController = ScrollController(
initialScrollOffset: currentIndex > 0
? currentIndex * 48.0 // Approximate height per ListTile
: 0,
);
return ListView.separated(
controller: scrollController,
itemCount: chapters.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
@@ -542,6 +616,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String previewContent,
String chapterId,
String chapterTitle,
String? nextChapterId,
int? chapterNumber,
) async {
await showModalBottomSheet<void>(
context: context,
@@ -847,6 +923,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: settings.enableSentenceTapTts,
onChanged: (enabled) {
unawaited(
ref
.read(readingSettingsProvider.notifier)
.setSentenceTapTtsEnabled(enabled),
);
},
title: const Text('Bật chạm câu để phát TTS'),
subtitle: const Text(
'Tắt để tránh chạm nhầm làm bắt đầu TTS.',
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
@@ -996,6 +1088,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,
@@ -1094,6 +1189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
chapter.id,
'Chương ${chapter.number}: ${chapter.title}',
chapter.nextChapterId,
chapter.number,
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
@@ -1166,6 +1263,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.titleLarge
?.copyWith(color: readerTextColor),
),
const SizedBox(height: 12),
_NavButtons(
chapter: chapter,
onGoPrevious: () => _goToPreviousChapter(chapter),
onGoNext: () => _goToNextChapter(chapter),
),
const SizedBox(height: 20),
if (chapter.content.trim().isEmpty)
Text(
@@ -1197,6 +1300,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: SizedBox(
width: double.infinity,
child: Padding(
key: _paragraphKeys[index],
padding: EdgeInsets.only(
@@ -1215,14 +1320,37 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) {
final hasActiveTtsSession =
tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing ||
tts.status == TtsStatus.paused);
final canStartFromSentence =
settings.enableSentenceTapTts || hasActiveTtsSession;
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)),
),
),
),
),
@@ -1316,6 +1444,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,
),
),
);
@@ -1614,20 +1745,18 @@ class _NavButtons extends StatelessWidget {
children: [
if (chapter.prevChapterId != null)
Expanded(
child: OutlinedButton.icon(
child: OutlinedButton(
onPressed: onGoPrevious,
icon: const Icon(Icons.chevron_left),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
),
),
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
const SizedBox(width: 12),
if (chapter.nextChapterId != null)
Expanded(
child: FilledButton.icon(
child: FilledButton(
onPressed: onGoNext,
icon: const Icon(Icons.chevron_right),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
),
),
],
@@ -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 (_) {}
}
@@ -145,6 +162,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings);
}
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
}
final readingSettingsProvider =
+94 -13
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;
@@ -155,6 +157,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
@@ -315,6 +318,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
Future<void> _ensureAndroidFallbackReady() async {
if (_androidFallbackReady) return;
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
await _configureVietnameseVoiceWithFlutterTts();
await _tts.setSpeechRate(state.speed);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
} else {
state = state.copyWith(status: TtsStatus.playing);
}
});
_tts.setCompletionHandler(() {
// Fallback playback progression is driven by _playFallbackFromGeneration.
});
_tts.setErrorHandler((_) {
if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
});
_androidFallbackReady = true;
}
Future<void> _startFallbackReading({
required int validIndex,
required _TtsSegment selectedSegment,
required String? contentKey,
}) async {
await _ensureAndroidFallbackReady();
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
@@ -372,6 +442,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
// Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw
.replaceAll(RegExp(r'["“”]'), ' ')
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
@@ -566,12 +637,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,
@@ -614,33 +692,36 @@ class TtsNotifier extends StateNotifier<TtsState> {
contentKey: contentKey,
);
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(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
return;
}
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<int> _interruptFallbackPlayback() async {
@@ -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);
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.2+3
version: 1.0.3+4
environment:
sdk: ^3.11.3