diff --git a/README.md b/README.md index 1cc6eab..a0c4978 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,11 @@ Optional (iOS/web): ```bash --dart-define=GOOGLE_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) diff --git a/android/app/src/main/kotlin/com/example/reader_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/reader_app/MainActivity.kt index e30fc09..238229c 100644 --- a/android/app/src/main/kotlin/com/example/reader_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/reader_app/MainActivity.kt @@ -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("startIndex") ?: 0 + val content = call.argument("content") ?: "" val contentKey = call.argument("contentKey") val title = call.argument("title") val speed = call.argument("speed") ?: 0.9 val language = call.argument("language") ?: "vi-VN" val voiceName = call.argument("voiceName") val backgroundModeEnabled = call.argument("backgroundModeEnabled") ?: true + val nextChapterId = call.argument("nextChapterId") + val chapterNumber = call.argument("chapterNumber") + val includeTitle = call.argument("includeTitle") ?: true + val apiBaseUrl = call.argument("apiBaseUrl") + val startIndex = call.argument("startIndex") ?: 0 ReaderTtsMediaService.startReading( this, - parseSegments(call.argument>("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 { - val segments = arrayListOf() - 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 diff --git a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt index 38db6c0..92dc133 100644 --- a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt +++ b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsMediaService.kt @@ -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, - 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) - applyVoiceAndSpeedSettings() + if (isTtsReady) { + applyVoiceAndSpeedSettings() + } publishSnapshot() } ACTION_SET_VOICE -> { voiceName = intent.getStringExtra(EXTRA_VOICE_NAME) language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language - applyVoiceAndSpeedSettings() + 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 { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java) - ?: arrayListOf() - } else { - @Suppress("DEPRECATION") - (intent.getParcelableArrayListExtra(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 { + val builtSegments = mutableListOf() + 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() } } diff --git a/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsPlaybackStore.kt b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsPlaybackStore.kt new file mode 100644 index 0000000..e4ff88b --- /dev/null +++ b/android/app/src/main/kotlin/com/example/reader_app/tts/ReaderTtsPlaybackStore.kt @@ -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() + + @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) + } +} \ No newline at end of file diff --git a/lib/app/app.dart b/lib/app/app.dart index 0bcb5c2..6d6db29 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 { final _scaffoldMessengerKey = GlobalKey(); ProviderSubscription? _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 { @override void dispose() { + _router.routerDelegate.removeListener(_persistRouteForRestore); _sessionExpirySub?.close(); super.dispose(); } diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 0d3163f..e12be6b 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -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'; diff --git a/lib/core/models/bookmark_model.dart b/lib/core/models/bookmark_model.dart index 564f90e..4049770 100644 --- a/lib/core/models/bookmark_model.dart +++ b/lib/core/models/bookmark_model.dart @@ -39,13 +39,28 @@ class BookmarkModel extends Equatable { factory BookmarkModel.fromJson(Map 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?) ?.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?) + ?.map((e) => (e as num).toInt()) + .toList() ?? + const []; + return (inferredLastChapter != null || inferredReadChapters.isNotEmpty) + ? BookmarkType.reading + : BookmarkType.bookmarked; + }(), novel: json['novel'] != null ? NovelModel.fromJson(json['novel'] as Map) : null, diff --git a/lib/core/storage/local_store.dart b/lib/core/storage/local_store.dart index 7895a1c..7db8ff4 100644 --- a/lib/core/storage/local_store.dart +++ b/lib/core/storage/local_store.dart @@ -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 saveLastRoutePath(String path) async { + final normalized = path.trim(); + if (normalized.isEmpty || normalized == '/') return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kLastRoutePath, normalized); + } + + Future loadLastRoutePath() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getString(_kLastRoutePath)?.trim(); + if (value == null || value.isEmpty) return null; + return value; + } + + Future clearLastRoutePath() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kLastRoutePath); + } } final localStoreProvider = Provider((_) => LocalStore()); diff --git a/lib/features/bookshelf/providers/bookshelf_provider.dart b/lib/features/bookshelf/providers/bookshelf_provider.dart index d2f652a..81a16dc 100644 --- a/lib/features/bookshelf/providers/bookshelf_provider.dart +++ b/lib/features/bookshelf/providers/bookshelf_provider.dart @@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier>> { } } + void syncProgress({ + required String novelId, + required String chapterId, + required int chapterNumber, + Map? serverBookmark, + }) { + final current = state.valueOrNull ?? const []; + + 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 toggle(String novelId) async { try { final client = _ref.read(apiClientProvider); diff --git a/lib/features/reader/presentation/reader_screen.dart b/lib/features/reader/presentation/reader_screen.dart index 375b960..c512545 100644 --- a/lib/features/reader/presentation/reader_screen.dart +++ b/lib/features/reader/presentation/reader_screen.dart @@ -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,38 +98,64 @@ class _ReaderScreenState extends ConsumerState { 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), + final spans = sentenceSlices.map((slice) { + final start = slice.start; + final end = slice.end; + + final isCurrentSpoken = isActiveParagraph && + highlightStart >= 0 && + highlightEnd > highlightStart && + start >= highlightStart && + end <= highlightEnd; + + if (!useTapRecognizer) { + return TextSpan( + text: slice.text, + style: isCurrentSpoken ? highlightStyle : null, + ); + } + + // Use WidgetSpan + GestureDetector to avoid lifecycle issues from + // creating/discarding many TapGestureRecognizer instances across rebuilds. + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + // Unfocus immediately so SelectableText drops its selection state + // before any scroll notification can call getBoxesForSelection. + FocusManager.instance.primaryFocus?.unfocus(); + onSentenceTap(start); + }, + child: Text( + slice.text, + style: isCurrentSpoken ? highlightStyle : style, + ), + ), + ); + }).toList(); + + final textSpan = TextSpan(style: style, children: spans); + + if (useTapRecognizer || sentenceSlices.isEmpty) { + // Use plain Text.rich when sentence-tap TTS is active. + // SelectableText keeps an internal TextEditingController/selection that + // becomes invalid after a programmatic rebuild, causing a Flutter + // assertion failure in getBoxesForSelection during scroll. + return GestureDetector( + onTap: sentenceSlices.isEmpty ? () => onSentenceTap(0) : null, + child: RichText(text: textSpan, textAlign: textAlign), ); } - return SelectableText.rich( - TextSpan( - style: style, - children: sentenceSlices.map((slice) { - final start = slice.start; - final end = slice.end; - - final isCurrentSpoken = isActiveParagraph && - highlightStart >= 0 && - highlightEnd > highlightStart && - start >= highlightStart && - end <= highlightEnd; - - return TextSpan( - text: slice.text, - style: isCurrentSpoken ? highlightStyle : null, - recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start), - ); - }).toList(), - ), - textAlign: textAlign, - ); + return SelectableText.rich(textSpan, textAlign: textAlign); } List> _sentenceSlicesForChapter( @@ -250,7 +277,29 @@ class _ReaderScreenState extends ConsumerState { 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 +325,9 @@ class _ReaderScreenState extends ConsumerState { chapter.content, contentKey: chapter.id, title: 'Chương ${chapter.number}: ${chapter.title}', + nextChapterId: chapter.nextChapterId, + chapterNumber: chapter.number, + apiBaseUrl: AppConfig.baseUrl, ); _autoStartQueuedChapterId = null; }); @@ -415,9 +467,11 @@ class _ReaderScreenState extends ConsumerState { 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 +480,16 @@ class _ReaderScreenState extends ConsumerState { 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 +499,9 @@ class _ReaderScreenState extends ConsumerState { 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 +616,8 @@ class _ReaderScreenState extends ConsumerState { String previewContent, String chapterId, String chapterTitle, + String? nextChapterId, + int? chapterNumber, ) async { await showModalBottomSheet( context: context, @@ -1019,6 +1088,9 @@ class _ReaderScreenState extends ConsumerState { content: previewContent, contentKey: chapterId, title: 'Chương $chapterTitle', + nextChapterId: nextChapterId, + chapterNumber: chapterNumber, + apiBaseUrl: AppConfig.baseUrl, includeTitleOnStart: false, resolveStartParagraphIndex: _firstVisibleParagraphIndex, @@ -1117,6 +1189,8 @@ class _ReaderScreenState extends ConsumerState { chapter.content, chapter.id, 'Chương ${chapter.number}: ${chapter.title}', + chapter.nextChapterId, + chapter.number, ), barBackgroundColor: readerBackground, foregroundColor: readerTextColor, @@ -1255,14 +1329,27 @@ class _ReaderScreenState extends ConsumerState { 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 +1444,9 @@ class _ReaderScreenState extends ConsumerState { content: chapter.content, contentKey: chapter.id, title: 'Chương ${chapter.number}: ${chapter.title}', + nextChapterId: chapter.nextChapterId, + chapterNumber: chapter.number, + apiBaseUrl: AppConfig.baseUrl, ), ), ); diff --git a/lib/features/reader/presentation/tts_player_widget.dart b/lib/features/reader/presentation/tts_player_widget.dart index 7a59fa2..b38ec38 100644 --- a/lib/features/reader/presentation/tts_player_widget.dart +++ b/lib/features/reader/presentation/tts_player_widget.dart @@ -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, ), ); diff --git a/lib/features/reader/providers/reader_provider.dart b/lib/features/reader/providers/reader_provider.dart index 3d828a9..2deb647 100644 --- a/lib/features/reader/providers/reader_provider.dart +++ b/lib/features/reader/providers/reader_provider.dart @@ -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 { // 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? bookmarkJson; + if (data is Map) { + final bookmark = data['bookmark']; + if (bookmark is Map) { + bookmarkJson = bookmark; + } + } + + _ref.read(bookshelfProvider.notifier).syncProgress( + novelId: _novelId!, + chapterId: chapterId, + chapterNumber: chapterNumber, + serverBookmark: bookmarkJson, + ); } catch (_) {} } diff --git a/lib/features/reader/tts/tts_service.dart b/lib/features/reader/tts/tts_service.dart index 50c9429..0b74570 100644 --- a/lib/features/reader/tts/tts_service.dart +++ b/lib/features/reader/tts/tts_service.dart @@ -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; @@ -635,12 +637,19 @@ class TtsNotifier extends StateNotifier { 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 +694,18 @@ class TtsNotifier extends StateNotifier { try { await _mediaChannel.invokeMethod('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( diff --git a/lib/features/splash/presentation/splash_screen.dart b/lib/features/splash/presentation/splash_screen.dart index 3a2da51..c73b99b 100644 --- a/lib/features/splash/presentation/splash_screen.dart +++ b/lib/features/splash/presentation/splash_screen.dart @@ -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 createState() => _SplashScreenState(); + ConsumerState createState() => _SplashScreenState(); } -class _SplashScreenState extends State { +class _SplashScreenState extends ConsumerState { 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); }); }