4 Commits

Author SHA1 Message Date
virtus d4c6cdb013 feat: Add native TTS snapshot reconciliation and lifecycle management
Build Android AAB / build-aab (push) Successful in 12m11s
Build Android APK / build-apk (push) Successful in 14m12s
2026-04-27 00:58:51 +07:00
virtus c3e6d66f43 feat: Implement TTS playback store and enhance reading progress synchronization
Build Android APK / build-apk (push) Has been cancelled
Build Android AAB / build-aab (push) Has been cancelled
- Added ReaderTtsPlaybackStore to manage TTS start requests with a maximum of 4 pending requests.
- Updated app configuration to use a production API URL.
- Enhanced BookmarkModel to infer type when not provided by the API for backward compatibility.
- Introduced methods in LocalStore for saving, loading, and clearing the last route path.
- Implemented syncProgress method in BookshelfNotifier to update reading progress and bookmarks from the server.
- Modified ReaderScreen to handle chapter navigation and TTS playback more effectively, including auto-start logic.
- Updated TtsPlayerWidget to accept additional parameters for chapter navigation.
- Enhanced TtsNotifier to handle new parameters for TTS requests and manage playback state.
- Improved SplashScreen to restore the last visited route after splash screen display.
2026-04-27 00:48:05 +07:00
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
virtus 2b8fa4ee57 feat: Update app layout with MainAppHeader and enhance user settings interface
Build Android APK / build-apk (push) Successful in 19m27s
Build Android AAB / build-aab (push) Successful in 12m5s
2026-04-23 03:09:24 +07:00
24 changed files with 3495 additions and 1152 deletions
+8
View File
@@ -115,3 +115,11 @@ Optional (iOS/web):
```bash ```bash
--dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com --dart-define=GOOGLE_CLIENT_ID=<YOUR_IOS_OR_WEB_CLIENT_ID>.apps.googleusercontent.com
``` ```
Noted:
Với MIUI:
Cần hướng dẫn user (không thể fix bằng code)
MIUI AutoStart: User phải vào Cài đặt → Ứng dụng → [app] → AutoStart và bật thủ công
MIUI Battery Optimization: User phải vào Cài đặt → Pin → Ứng dụng tiêu hao pin → [app] → chọn "Không hạn chế" (permission REQUEST_IGNORE_BATTERY_OPTIMIZATIONS đã có trong Manifest để trigger dialog, nhưng user vẫn phải accept)
@@ -13,7 +13,7 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment import com.example.reader_app.tts.ReaderTtsStartRequest
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background" private val channelName = "reader_app/tts_background"
@@ -53,23 +53,34 @@ class MainActivity : FlutterActivity() {
} }
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot()) "getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> { "startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0 val content = call.argument<String>("content") ?: ""
val contentKey = call.argument<String>("contentKey") val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title") val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9 val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN" val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName") val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
val nextChapterId = call.argument<String>("nextChapterId")
val chapterNumber = call.argument<Int>("chapterNumber")
val includeTitle = call.argument<Boolean>("includeTitle") ?: true
val apiBaseUrl = call.argument<String>("apiBaseUrl")
val startIndex = call.argument<Int>("startIndex") ?: 0
ReaderTtsMediaService.startReading( ReaderTtsMediaService.startReading(
this, this,
parseSegments(call.argument<List<*>>("segments")), ReaderTtsStartRequest(
startIndex, content = content,
contentKey, contentKey = contentKey,
title, title = title,
speed, speed = speed,
language, language = language,
voiceName, voiceName = voiceName,
backgroundModeEnabled, backgroundModeEnabled = backgroundModeEnabled,
nextChapterId = nextChapterId,
chapterNumber = chapterNumber,
includeTitle = includeTitle,
apiBaseUrl = apiBaseUrl,
startIndex = startIndex,
),
) )
result.success(null) result.success(null)
} }
@@ -137,24 +148,6 @@ class MainActivity : FlutterActivity() {
) )
} }
private fun parseSegments(rawSegments: List<*>?): ArrayList<ReaderTtsSegment> {
val segments = arrayListOf<ReaderTtsSegment>()
rawSegments.orEmpty().forEach { item ->
val map = item as? Map<*, *> ?: return@forEach
val text = map["text"]?.toString() ?: return@forEach
val paragraphIndex = (map["paragraphIndex"] as? Number)?.toInt() ?: -1
val start = (map["start"] as? Number)?.toInt() ?: -1
val end = (map["end"] as? Number)?.toInt() ?: -1
segments += ReaderTtsSegment(
text = text,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
return segments
}
private fun isIgnoringBatteryOptimizations(): Boolean { private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest import android.media.AudioFocusRequest
import android.media.AudioManager import android.media.AudioManager
@@ -15,7 +16,6 @@ import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.Parcelable
import android.os.PowerManager import android.os.PowerManager
import android.speech.tts.TextToSpeech import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener import android.speech.tts.UtteranceProgressListener
@@ -28,16 +28,27 @@ import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R import com.example.reader_app.R
import kotlinx.parcelize.Parcelize import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.min
import java.util.Locale import java.util.Locale
import java.util.concurrent.Executors
@Parcelize
data class ReaderTtsSegment( data class ReaderTtsSegment(
val text: String, val text: String,
val paragraphIndex: Int, val paragraphIndex: Int,
val start: Int, val start: Int,
val end: Int, val end: Int,
) : Parcelable )
private data class ReaderRemoteChapter(
val id: String,
val number: Int?,
val title: String?,
val content: String,
val nextChapterId: String?,
)
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener { class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
companion object { companion object {
@@ -47,8 +58,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private const val BASE_SPEED = 0.9 private const val BASE_SPEED = 0.9
private const val TAG = "ReaderTtsMediaService" private const val TAG = "ReaderTtsMediaService"
private const val HEALTH_CHECK_INTERVAL_MS = 1500L private const val HEALTH_CHECK_INTERVAL_MS = 1500L
private const val START_GRACE_PERIOD_MS = 10_000L private const val START_GRACE_PERIOD_MS = 5_000L
private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4 private const val MAX_SEGMENT_RETRIES_BEFORE_SKIP = 4
private const val DEDUPE_START_WINDOW_MS = 600L
const val ACTION_INIT = "com.example.reader_app.tts.INIT" const val ACTION_INIT = "com.example.reader_app.tts.INIT"
const val ACTION_START_READING = "com.example.reader_app.tts.START_READING" const val ACTION_START_READING = "com.example.reader_app.tts.START_READING"
@@ -61,8 +73,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE" const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE"
const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE" const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
const val EXTRA_SEGMENTS = "segments" const val EXTRA_SESSION_TOKEN = "sessionToken"
const val EXTRA_START_INDEX = "startIndex"
const val EXTRA_CONTENT_KEY = "contentKey" const val EXTRA_CONTENT_KEY = "contentKey"
const val EXTRA_TITLE = "title" const val EXTRA_TITLE = "title"
const val EXTRA_SPEED = "speed" const val EXTRA_SPEED = "speed"
@@ -70,6 +81,8 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
const val EXTRA_VOICE_NAME = "voiceName" const val EXTRA_VOICE_NAME = "voiceName"
const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled" const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled"
const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey" const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey"
const val EXTRA_STOP_REASON = "stopReason"
private const val STOP_REASON_USER = "user"
fun initialize(context: Context, backgroundModeEnabled: Boolean) { fun initialize(context: Context, backgroundModeEnabled: Boolean) {
context.startService( context.startService(
@@ -80,31 +93,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
) )
} }
fun startReading( fun startReading(context: Context, request: ReaderTtsStartRequest): Boolean {
context: Context, return try {
segments: ArrayList<ReaderTtsSegment>, val sessionToken = ReaderTtsPlaybackStore.enqueue(request)
startIndex: Int, ContextCompat.startForegroundService(
contentKey: String?, context,
title: String?, Intent(context, ReaderTtsMediaService::class.java).apply {
speed: Double, action = ACTION_START_READING
language: String, putExtra(EXTRA_SESSION_TOKEN, sessionToken)
voiceName: String?, },
backgroundModeEnabled: Boolean, )
) { true
ContextCompat.startForegroundService( } catch (e: Throwable) {
context, Log.e(TAG, "startForegroundService blocked or failed", e)
Intent(context, ReaderTtsMediaService::class.java).apply { false
action = ACTION_START_READING }
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
putExtra(EXTRA_START_INDEX, startIndex)
putExtra(EXTRA_CONTENT_KEY, contentKey)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_SPEED, speed)
putExtra(EXTRA_LANGUAGE, language)
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
} }
fun pause(context: Context) = fun pause(context: Context) =
@@ -121,6 +124,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply { context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_STOP action = ACTION_STOP
putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey) putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey)
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
}) })
fun skipForward(context: Context) = fun skipForward(context: Context) =
@@ -179,11 +183,28 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private var currentUtteranceId: String? = null private var currentUtteranceId: String? = null
private var currentUtteranceStarted = false private var currentUtteranceStarted = false
private var pendingReplayAfterInit = false private var pendingReplayAfterInit = false
private var isRebuildingEngine = false
private var engineRebuildAttempt = 0
private var audioFocusRetryAttempt = 0
private var consecutivePlaybackRecoveryFailures = 0
private var pendingEngineRebuild: Runnable? = null
private var pendingAudioFocusRetry: Runnable? = null
private var pendingIdleStop: Runnable? = null
private var currentSegmentRetry = 0 private var currentSegmentRetry = 0
private var consecutiveSilentHealthChecks = 0 private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null private var utteranceWatchdog: Runnable? = null
private var pausedByAudioFocus = false private var pausedByAudioFocus = false
private var isDuckedByAudioFocus = false
private var volumeMultiplier = 1.0f
private var lastSpeakRequestTimeMs = 0L private var lastSpeakRequestTimeMs = 0L
private var nextChapterId: String? = null
private var chapterNumber: Int? = null
private var includeChapterTitleInPlayback = true
private var apiBaseUrl: String? = null
private var isPreparingNextChapter = false
private var lastStartSignature: String? = null
private var lastStartRequestAtMs = 0L
private val networkExecutor = Executors.newSingleThreadExecutor()
private val playbackHealthRunnable = object : Runnable { private val playbackHealthRunnable = object : Runnable {
override fun run() { override fun run() {
runPlaybackHealthCheck() runPlaybackHealthCheck()
@@ -196,15 +217,27 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
when (focusChange) { when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
clearDuckingState(restartPlayback = false)
if (status == "playing") { if (status == "playing") {
pausedByAudioFocus = true pausedByAudioFocus = true
handlePause() handlePause()
} }
} }
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> handleDuckAudioFocusLoss()
AudioManager.AUDIOFOCUS_GAIN -> { AudioManager.AUDIOFOCUS_GAIN -> {
val shouldRestorePlaybackVolume = isDuckedByAudioFocus
clearDuckingState(
restartPlayback = shouldRestorePlaybackVolume && status == "playing",
)
if (pausedByAudioFocus && status == "paused") { if (pausedByAudioFocus && status == "paused") {
pausedByAudioFocus = false pausedByAudioFocus = false
handleResume() handleResume()
} else if (pausedByAudioFocus && status == "playing") {
// Delayed focus grant arrived while status was already "playing"
// (set optimistically). Treat same as resume.
pausedByAudioFocus = false
clearAudioFocusRetry()
speakCurrentSegment(forceRestart = true)
} }
} }
} }
@@ -218,6 +251,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
createNotificationChannel() createNotificationChannel()
setupMediaSession() setupMediaSession()
// Call startForeground() IMMEDIATELY in onCreate() before any async work.
// Android O+ (and MIUI strictly enforced) requires startForeground() to be called
// within 5 seconds of startForegroundService(). TTS engine init is async and may
// take longer on cold start / low-end devices, so we must not wait for it.
isForegroundActive = startForegroundCompat(buildIdleNotification())
setupTextToSpeech() setupTextToSpeech()
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS) mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
publishSnapshot() publishSnapshot()
@@ -226,6 +264,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "onStartCommand action=${intent?.action} status=$status index=$currentIndex")
when (intent?.action) { when (intent?.action) {
ACTION_INIT -> { ACTION_INIT -> {
backgroundModeEnabled = intent.getBooleanExtra( backgroundModeEnabled = intent.getBooleanExtra(
@@ -239,18 +278,23 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
ACTION_RESUME -> handleResume() ACTION_RESUME -> handleResume()
ACTION_STOP -> handleStop( ACTION_STOP -> handleStop(
clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true), clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true),
reason = intent.getStringExtra(EXTRA_STOP_REASON) ?: "unknown",
) )
ACTION_SKIP_FORWARD -> handleSkip(1) ACTION_SKIP_FORWARD -> handleSkip(1)
ACTION_SKIP_BACK -> handleSkip(-1) ACTION_SKIP_BACK -> handleSkip(-1)
ACTION_SET_SPEED -> { ACTION_SET_SPEED -> {
speed = intent.getDoubleExtra(EXTRA_SPEED, speed) speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
applyVoiceAndSpeedSettings() if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot() publishSnapshot()
} }
ACTION_SET_VOICE -> { ACTION_SET_VOICE -> {
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME) voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
applyVoiceAndSpeedSettings() if (isTtsReady) {
applyVoiceAndSpeedSettings()
}
publishSnapshot() publishSnapshot()
} }
ACTION_SET_BACKGROUND_MODE -> { ACTION_SET_BACKGROUND_MODE -> {
@@ -306,7 +350,14 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (!isActiveUtterance(utteranceId)) return@post if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
recoverFromSilentPlayback("utterance_error_$errorCode") // ERROR_SERVICE (-6) means the TTS engine process disconnected.
// Rebuild the engine immediately rather than retrying on a dead instance.
if (errorCode == TextToSpeech.ERROR_SERVICE ||
errorCode == TextToSpeech.ERROR_NOT_INSTALLED_YET) {
rebuildTtsEngineForRecovery("utterance_error_$errorCode")
} else {
recoverFromSilentPlayback("utterance_error_$errorCode")
}
} }
} }
}, },
@@ -314,8 +365,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
override fun onInit(initStatus: Int) { override fun onInit(initStatus: Int) {
isRebuildingEngine = false
isTtsReady = initStatus == TextToSpeech.SUCCESS isTtsReady = initStatus == TextToSpeech.SUCCESS
if (isTtsReady) { if (isTtsReady) {
engineRebuildAttempt = 0
consecutivePlaybackRecoveryFailures = 0
currentSegmentRetry = 0 // reset retry counter after successful engine reconnect
refreshAvailableVoices() refreshAvailableVoices()
applyVoiceAndSpeedSettings() applyVoiceAndSpeedSettings()
if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) { if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) {
@@ -323,7 +378,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
} else { } else {
status = "idle" if (status == "playing" || pendingReplayAfterInit || segments.isNotEmpty()) {
status = "paused"
scheduleEngineRebuild("onInit_failed_$initStatus")
} else {
status = "idle"
}
} }
syncPowerState() syncPowerState()
syncNotificationState() syncNotificationState()
@@ -375,20 +435,53 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handleStartReading(intent: Intent) { private fun handleStartReading(intent: Intent) {
backgroundModeEnabled = intent.getBooleanExtra( val request = ReaderTtsPlaybackStore.consume(intent.getStringExtra(EXTRA_SESSION_TOKEN))
EXTRA_BACKGROUND_MODE_ENABLED, if (request == null) {
backgroundModeEnabled, 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 = 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) currentIndex = request.startIndex.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
contentKey = intent.getStringExtra(EXTRA_CONTENT_KEY)
title = intent.getStringExtra(EXTRA_TITLE)
segments = extractSegments(intent)
currentIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "playing" status = "playing"
pausedByAudioFocus = false pausedByAudioFocus = false
pendingReplayAfterInit = false pendingReplayAfterInit = false
@@ -396,6 +489,11 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
syncPowerState() syncPowerState()
publishSnapshot() publishSnapshot()
if (segments.isEmpty()) {
handleStop(clearContentKey = false, reason = "empty_segments")
return
}
if (!isTtsReady) return if (!isTtsReady) return
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
@@ -405,6 +503,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
status = "paused" status = "paused"
isPreparingNextChapter = false
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
syncPowerState() syncPowerState()
@@ -414,7 +513,9 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun handleResume() { private fun handleResume() {
if (segments.isEmpty()) return if (segments.isEmpty()) return
cancelIdleStop()
status = "playing" status = "playing"
isPreparingNextChapter = false
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
@@ -424,13 +525,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
speakCurrentSegment(forceRestart = true) speakCurrentSegment(forceRestart = true)
} }
private fun handleStop(clearContentKey: Boolean) { private fun handleStop(clearContentKey: Boolean, reason: String) {
Log.i(TAG, "handleStop reason=$reason clearContentKey=$clearContentKey")
sessionGeneration += 1 sessionGeneration += 1
clearScheduledRecoveries()
cancelIdleStop()
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
isPreparingNextChapter = false
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
segments = emptyList() segments = emptyList()
title = null title = null
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
if (clearContentKey) { if (clearContentKey) {
contentKey = null contentKey = null
} }
@@ -449,6 +558,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
currentIndex = nextIndex currentIndex = nextIndex
sessionGeneration += 1 sessionGeneration += 1
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing" status = "playing"
pendingReplayAfterInit = false pendingReplayAfterInit = false
tts?.stop() tts?.stop()
@@ -464,15 +574,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val nextIndex = currentIndex + 1 val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) { if (nextIndex >= segments.size) {
status = "idle" handleChapterCompleted()
currentIndex = 0
completedCount += 1
clearUtteranceRuntimeState()
abandonAudioFocus()
syncPowerState()
syncNotificationState()
publishSnapshot()
stopSelf()
return return
} }
@@ -481,23 +583,36 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun handlePlaybackFailure() { private fun handlePlaybackFailure() {
Log.e(TAG, "Playback stopped after recovery failed at index=$currentIndex contentKey=$contentKey") consecutivePlaybackRecoveryFailures += 1
status = "idle" Log.e(
clearUtteranceRuntimeState() TAG,
pendingReplayAfterInit = false "Playback failure at index=$currentIndex contentKey=$contentKey, recoveryAttempt=$consecutivePlaybackRecoveryFailures",
abandonAudioFocus() )
status = "paused"
pendingReplayAfterInit = true
if (consecutivePlaybackRecoveryFailures > 12) {
// Keep trying indefinitely but avoid a tight error loop.
consecutivePlaybackRecoveryFailures = 6
}
syncPowerState() syncPowerState()
syncNotificationState() syncNotificationState()
publishSnapshot() publishSnapshot()
stopSelf() scheduleEngineRebuild("playback_failure")
} }
private fun speakCurrentSegment(forceRestart: Boolean) { private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return if (segments.isEmpty() || !isTtsReady) return
isPreparingNextChapter = false
if (!requestAudioFocus()) { if (!requestAudioFocus()) {
handlePlaybackFailure() pausedByAudioFocus = true
status = "paused"
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleAudioFocusRetry()
return return
} }
clearAudioFocusRetry()
val segment = segments.getOrNull(currentIndex) ?: run { val segment = segments.getOrNull(currentIndex) ?: run {
handlePlaybackFailure() handlePlaybackFailure()
@@ -522,10 +637,20 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
scheduleUtteranceWatchdog(utteranceId) scheduleUtteranceWatchdog(utteranceId)
val speakResult = try { val speakResult = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId) val params = Bundle().apply {
putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, volumeMultiplier)
}
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, params, utteranceId)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null) tts?.speak(
segment.text,
TextToSpeech.QUEUE_FLUSH,
hashMapOf(
TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID to utteranceId,
TextToSpeech.Engine.KEY_PARAM_VOLUME to volumeMultiplier.toString(),
),
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "speak() failed for index=$currentIndex", e) Log.e(TAG, "speak() failed for index=$currentIndex", e)
@@ -533,7 +658,12 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
if (speakResult == null || speakResult == TextToSpeech.ERROR) { if (speakResult == null || speakResult == TextToSpeech.ERROR) {
recoverFromSilentPlayback("speak_error_or_null") // speak() returning ERROR/null almost always means the TTS engine process died
// (visible in logcat as "Disconnected from TTS engine").
// Rebuild immediately instead of burning 3 retries on a dead engine.
if (!isRebuildingEngine) {
rebuildTtsEngineForRecovery("speak_error_or_null")
}
} }
} }
@@ -597,11 +727,21 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
private fun rebuildTtsEngineForRecovery(reason: String) { private fun rebuildTtsEngineForRecovery(reason: String) {
if (isRebuildingEngine) {
Log.w(TAG, "Rebuild already in progress, skipping: $reason")
return
}
Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason") Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason")
isRebuildingEngine = true
pendingReplayAfterInit = true pendingReplayAfterInit = true
isTtsReady = false isTtsReady = false
clearScheduledRecoveries()
// Increment session so callbacks from the dying engine are ignored
sessionGeneration += 1
clearUtteranceRuntimeState()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
tts = null
setupTextToSpeech() setupTextToSpeech()
} }
@@ -640,7 +780,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
if (!isTtsReady) { if (!isTtsReady) {
if (!pendingReplayAfterInit) { if (!pendingReplayAfterInit && !isRebuildingEngine) {
rebuildTtsEngineForRecovery("tts_not_ready") rebuildTtsEngineForRecovery("tts_not_ready")
} }
return return
@@ -691,11 +831,15 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(), .build(),
) )
.setAcceptsDelayedFocusGain(false) .setAcceptsDelayedFocusGain(true)
.setWillPauseWhenDucked(false)
.setOnAudioFocusChangeListener(audioFocusListener) .setOnAudioFocusChangeListener(audioFocusListener)
.build() .build()
.also { audioFocusRequest = it } .also { audioFocusRequest = it }
audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED 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 { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
audioManager.requestAudioFocus( audioManager.requestAudioFocus(
@@ -706,6 +850,61 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
} }
} }
private fun scheduleEngineRebuild(reason: String) {
if (isRebuildingEngine) return
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
engineRebuildAttempt += 1
val delayMs = min(30_000L, 1_000L * engineRebuildAttempt * engineRebuildAttempt)
pendingEngineRebuild = Runnable {
if (status == "idle") return@Runnable
if (segments.isEmpty()) return@Runnable
rebuildTtsEngineForRecovery(reason)
}.also { mainHandler.postDelayed(it, delayMs) }
}
private fun scheduleAudioFocusRetry() {
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
audioFocusRetryAttempt += 1
val delayMs = min(6_000L, 1_000L + (audioFocusRetryAttempt * 500L))
pendingAudioFocusRetry = Runnable {
if (status == "idle") return@Runnable
if (!pausedByAudioFocus) return@Runnable
if (requestAudioFocus()) {
pausedByAudioFocus = false
audioFocusRetryAttempt = 0
handleResume()
return@Runnable
}
scheduleAudioFocusRetry()
}.also { mainHandler.postDelayed(it, delayMs) }
}
private fun clearAudioFocusRetry() {
pendingAudioFocusRetry?.let(mainHandler::removeCallbacks)
pendingAudioFocusRetry = null
audioFocusRetryAttempt = 0
}
private fun clearScheduledRecoveries() {
pendingEngineRebuild?.let(mainHandler::removeCallbacks)
pendingEngineRebuild = null
clearAudioFocusRetry()
}
private fun scheduleIdleStop() {
pendingIdleStop?.let(mainHandler::removeCallbacks)
pendingIdleStop = Runnable {
if (status != "idle") return@Runnable
Log.i(TAG, "idle_timeout_stop")
stopSelf()
}.also { mainHandler.postDelayed(it, 30_000L) }
}
private fun cancelIdleStop() {
pendingIdleStop?.let(mainHandler::removeCallbacks)
pendingIdleStop = null
}
private fun abandonAudioFocus() { private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let(audioManager::abandonAudioFocusRequest) audioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
@@ -721,7 +920,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
wakeLock = powerManager.newWakeLock( wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
"reader_app:ReaderTtsPlayback" "$packageName:ReaderTtsPlayback"
).apply { ).apply {
setReferenceCounted(false) setReferenceCounted(false)
acquire() acquire()
@@ -750,6 +949,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex) private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
private fun currentProgressLabel(): String { private fun currentProgressLabel(): String {
if (isPreparingNextChapter) return "Đang tải chương tiếp theo"
if (segments.isEmpty()) return voiceName ?: language if (segments.isEmpty()) return voiceName ?: language
return "Câu ${currentIndex + 1}/${segments.size}" return "Câu ${currentIndex + 1}/${segments.size}"
} }
@@ -778,21 +978,32 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
this.action = action this.action = action
if (action == ACTION_STOP) { if (action == ACTION_STOP) {
putExtra(EXTRA_CLEAR_CONTENT_KEY, true) putExtra(EXTRA_CLEAR_CONTENT_KEY, true)
putExtra(EXTRA_STOP_REASON, STOP_REASON_USER)
} }
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
} }
/** Minimal notification used in onCreate() to satisfy the 5-second startForeground() rule. */
private fun buildIdleNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle(appLabel())
.setContentText("Đang khởi động TTS…")
.setOngoing(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.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()) .setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel()) .setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent()) .setContentIntent(buildLaunchIntent())
.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setOngoing(status == "playing") .setOngoing(status == "playing" || status == "paused")
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.addAction( .addAction(
@@ -828,7 +1039,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
object : MediaSessionCompat.Callback() { object : MediaSessionCompat.Callback() {
override fun onPlay() = handleResume() override fun onPlay() = handleResume()
override fun onPause() = handlePause() override fun onPause() = handlePause()
override fun onStop() = handleStop(clearContentKey = true) override fun onStop() = handleStop(clearContentKey = true, reason = STOP_REASON_USER)
override fun onSkipToNext() = handleSkip(1) override fun onSkipToNext() = handleSkip(1)
override fun onSkipToPrevious() = handleSkip(-1) override fun onSkipToPrevious() = handleSkip(-1)
}, },
@@ -873,27 +1084,24 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
isForegroundActive = false isForegroundActive = false
} }
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
// Even without background mode, keep foreground service alive while playing
// to prevent Android from killing us.
if (status == "playing" || status == "paused") {
val notification = buildNotification()
isForegroundActive = startForegroundCompat(notification)
}
return return
} }
when (status) { when (status) {
"playing" -> { "playing", "paused" -> {
val notification = buildNotification() val notification = buildNotification()
if (!isForegroundActive) { if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification) isForegroundActive = startForegroundCompat(notification)
isForegroundActive = true
} else { } else {
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
} }
} }
"paused" -> {
val notification = buildNotification()
if (isForegroundActive) {
stopForeground(false)
isForegroundActive = false
}
notificationManager.notify(NOTIFICATION_ID, notification)
}
else -> { else -> {
if (isForegroundActive) { if (isForegroundActive) {
stopForeground(true) stopForeground(true)
@@ -904,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() { private fun publishSnapshot() {
val segment = currentSegment() val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
@@ -930,6 +1156,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
"contentKey" to contentKey, "contentKey" to contentKey,
"completedCount" to completedCount, "completedCount" to completedCount,
"backgroundModeEnabled" to backgroundModeEnabled, "backgroundModeEnabled" to backgroundModeEnabled,
"isPreparingNextChapter" to isPreparingNextChapter,
"language" to language, "language" to language,
"voiceName" to voiceName, "voiceName" to voiceName,
"availableVietnameseVoices" to availableVoices, "availableVietnameseVoices" to availableVoices,
@@ -943,32 +1170,271 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
CHANNEL_NAME, CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW, // IMPORTANCE_DEFAULT is required on MIUI 12+ so the system does not demote
// the foreground service. Sound/vibration are disabled explicitly so the user
// is not disturbed despite the higher importance level.
NotificationManager.IMPORTANCE_DEFAULT,
).apply { ).apply {
description = "Điều khiển đọc truyện bằng TTS" description = "Điều khiển đọc truyện bằng TTS"
setShowBadge(false) setShowBadge(false)
setSound(null, null)
enableLights(false)
enableVibration(false)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
private fun extractSegments(intent: Intent): List<ReaderTtsSegment> { private fun sanitizeForTts(raw: String): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (raw.isBlank()) return raw
intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java) return raw
?: arrayListOf() .replace(Regex("[\"“”]"), " ")
} else { .replace(Regex("[_\\$#^*+=~`|<>\\\\\\[\\]{}]"), " ")
@Suppress("DEPRECATION") .replace(Regex("\\s+"), " ")
(intent.getParcelableArrayListExtra<ReaderTtsSegment>(EXTRA_SEGMENTS) .trim()
?: arrayListOf()) }
private fun buildSegments(
content: String,
title: String?,
includeTitle: Boolean,
): List<ReaderTtsSegment> {
val builtSegments = mutableListOf<ReaderTtsSegment>()
val trimmedTitle = title?.trim().orEmpty()
if (includeTitle && trimmedTitle.isNotEmpty()) {
val sanitizedTitle = sanitizeForTts(trimmedTitle)
if (sanitizedTitle.isNotEmpty()) {
builtSegments += ReaderTtsSegment(
text = sanitizedTitle,
paragraphIndex = -1,
start = -1,
end = -1,
)
}
} }
val paragraphs = content
.split(Regex("\\n+"))
.map(String::trim)
.filter(String::isNotEmpty)
val sentenceRegex = Regex("[^.!?…]+[.!?…]*")
paragraphs.forEachIndexed { paragraphIndex, paragraph ->
var cursor = 0
sentenceRegex.findAll(paragraph).forEach { match ->
val sentence = match.value.trim()
if (sentence.isEmpty()) return@forEach
val sanitizedSentence = sanitizeForTts(sentence)
if (sanitizedSentence.isEmpty()) return@forEach
var start = paragraph.indexOf(sentence, cursor)
if (start < 0) {
start = cursor.coerceIn(0, paragraph.length)
}
val end = (start + sentence.length).coerceIn(0, paragraph.length)
cursor = end
builtSegments += ReaderTtsSegment(
text = sanitizedSentence,
paragraphIndex = paragraphIndex,
start = start,
end = end,
)
}
}
return builtSegments
}
private fun handleChapterCompleted() {
clearUtteranceRuntimeState()
val nextId = nextChapterId
if (nextId.isNullOrBlank() || apiBaseUrl.isNullOrBlank()) {
finishPlaybackAfterChapterCompletion()
return
}
isPreparingNextChapter = true
syncNotificationState()
publishSnapshot()
fetchAndPlayNextChapter(nextId, sessionGeneration)
}
private fun finishPlaybackAfterChapterCompletion() {
status = "idle"
currentIndex = 0
completedCount += 1
Log.i(TAG, "chapter_completed contentKey=$contentKey completedCount=$completedCount")
clearUtteranceRuntimeState()
clearDuckingState(restartPlayback = false)
abandonAudioFocus()
syncPowerState()
syncNotificationState()
publishSnapshot()
scheduleIdleStop()
}
private fun fetchAndPlayNextChapter(chapterId: String, generation: Int) {
networkExecutor.execute {
val remoteChapter = fetchChapterWithRetries(chapterId)
mainHandler.post {
if (generation != sessionGeneration || status == "idle") return@post
if (remoteChapter == null) {
Log.e(TAG, "Failed to fetch next chapter chapterId=$chapterId")
isPreparingNextChapter = false
finishPlaybackAfterChapterCompletion()
return@post
}
adoptRemoteChapter(remoteChapter)
}
}
}
private fun fetchChapterWithRetries(chapterId: String): ReaderRemoteChapter? {
repeat(3) { attempt ->
try {
return fetchChapter(chapterId)
} catch (error: Throwable) {
Log.w(TAG, "fetch_next_chapter_failed attempt=${attempt + 1} chapterId=$chapterId", error)
if (attempt < 2) {
Thread.sleep((750L * (attempt + 1)).coerceAtMost(2_500L))
}
}
}
return null
}
private fun fetchChapter(chapterId: String): ReaderRemoteChapter {
val baseUrl = apiBaseUrl?.trimEnd('/') ?: error("Missing api base URL for TTS service")
val connection = (URL("$baseUrl/api/chapters/$chapterId").openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 20_000
readTimeout = 20_000
setRequestProperty("Accept", "application/json")
}
try {
val statusCode = connection.responseCode
val responseBody = (if (statusCode in 200..299) {
connection.inputStream
} else {
connection.errorStream
})?.bufferedReader()?.use { it.readText() }.orEmpty()
if (statusCode !in 200..299) {
error("HTTP $statusCode when fetching chapter $chapterId: $responseBody")
}
val json = JSONObject(responseBody)
val id = json.optString("id").takeIf { it.isNotBlank() }
?: error("Chapter payload missing id")
val title = json.optString("title").takeIf { it.isNotBlank() }
val content = json.optString("content")
val nextId = json.optString("nextChapterId").takeIf { it.isNotBlank() }
val number = if (json.isNull("number")) null else json.optInt("number")
return ReaderRemoteChapter(
id = id,
number = number,
title = title,
content = content,
nextChapterId = nextId,
)
} finally {
connection.disconnect()
}
}
private fun adoptRemoteChapter(remoteChapter: ReaderRemoteChapter) {
val nextTitle = buildChapterTitle(remoteChapter.number, remoteChapter.title)
val nextSegments = buildSegments(
content = remoteChapter.content,
title = nextTitle,
includeTitle = includeChapterTitleInPlayback,
)
if (nextSegments.isEmpty()) {
Log.e(TAG, "Fetched next chapter has no readable segments id=${remoteChapter.id}")
isPreparingNextChapter = false
finishPlaybackAfterChapterCompletion()
return
}
completedCount += 1
contentKey = remoteChapter.id
title = nextTitle
chapterNumber = remoteChapter.number
nextChapterId = remoteChapter.nextChapterId
segments = nextSegments
currentIndex = 0
sessionGeneration += 1
clearUtteranceRuntimeState()
isPreparingNextChapter = false
status = "playing"
pausedByAudioFocus = false
pendingReplayAfterInit = false
clearDuckingState(restartPlayback = false)
publishSnapshot()
if (!isTtsReady) {
pendingReplayAfterInit = true
scheduleEngineRebuild("next_chapter_tts_not_ready")
return
}
speakCurrentSegment(forceRestart = true)
}
private fun buildChapterTitle(number: Int?, rawTitle: String?): String? {
val trimmedTitle = rawTitle?.trim().orEmpty()
return when {
number != null && trimmedTitle.isNotEmpty() -> "Chương $number: $trimmedTitle"
number != null -> "Chương $number"
trimmedTitle.isNotEmpty() -> trimmedTitle
else -> null
}
}
private fun handleDuckAudioFocusLoss() {
pausedByAudioFocus = false
if (isDuckedByAudioFocus) return
isDuckedByAudioFocus = true
volumeMultiplier = 0.35f
if (status == "playing" && !isPreparingNextChapter) {
restartCurrentSegmentForFocusChange()
}
}
private fun clearDuckingState(restartPlayback: Boolean) {
if (!isDuckedByAudioFocus && volumeMultiplier == 1.0f) return
isDuckedByAudioFocus = false
volumeMultiplier = 1.0f
if (restartPlayback && status == "playing" && !isPreparingNextChapter) {
restartCurrentSegmentForFocusChange()
}
}
private fun restartCurrentSegmentForFocusChange() {
if (segments.isEmpty() || !isTtsReady) return
sessionGeneration += 1
clearUtteranceRuntimeState()
tts?.stop()
speakCurrentSegment(forceRestart = true)
} }
override fun onDestroy() { override fun onDestroy() {
mainHandler.removeCallbacks(playbackHealthRunnable) mainHandler.removeCallbacks(playbackHealthRunnable)
isRebuildingEngine = false
clearScheduledRecoveries()
cancelIdleStop()
status = "idle" status = "idle"
currentIndex = 0 currentIndex = 0
segments = emptyList() segments = emptyList()
nextChapterId = null
chapterNumber = null
apiBaseUrl = null
isPreparingNextChapter = false
clearUtteranceRuntimeState() clearUtteranceRuntimeState()
pendingReplayAfterInit = false pendingReplayAfterInit = false
clearDuckingState(restartPlayback = false)
publishSnapshot() publishSnapshot()
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
@@ -979,6 +1445,7 @@ class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
isForegroundActive = false isForegroundActive = false
} }
mediaSession.release() mediaSession.release()
networkExecutor.shutdownNow()
super.onDestroy() super.onDestroy()
} }
} }
@@ -0,0 +1,41 @@
package com.example.reader_app.tts
import java.util.LinkedHashMap
import java.util.UUID
data class ReaderTtsStartRequest(
val content: String,
val contentKey: String?,
val title: String?,
val speed: Double,
val language: String,
val voiceName: String?,
val backgroundModeEnabled: Boolean,
val nextChapterId: String?,
val chapterNumber: Int?,
val includeTitle: Boolean,
val apiBaseUrl: String?,
val startIndex: Int = 0,
)
object ReaderTtsPlaybackStore {
private const val MAX_PENDING_REQUESTS = 4
private val pendingRequests = LinkedHashMap<String, ReaderTtsStartRequest>()
@Synchronized
fun enqueue(request: ReaderTtsStartRequest): String {
val token = UUID.randomUUID().toString()
pendingRequests[token] = request
while (pendingRequests.size > MAX_PENDING_REQUESTS) {
val oldestKey = pendingRequests.entries.firstOrNull()?.key ?: break
pendingRequests.remove(oldestKey)
}
return token
}
@Synchronized
fun consume(token: String?): ReaderTtsStartRequest? {
if (token.isNullOrBlank()) return null
return pendingRequests.remove(token)
}
}
+63
View File
@@ -1,9 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart'; import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart'; import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart'; import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart'; import 'router/route_names.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
@@ -17,10 +23,27 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> { class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>(); final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub; ProviderSubscription<int>? _sessionExpirySub;
late final GoRouter _router;
void _persistRouteForRestore() {
if (!mounted) return;
unawaited(() async {
final uri = _router.state.uri;
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
if (fullPath == RouteNames.splash) return;
await ref.read(localStoreProvider).saveLastRoutePath(fullPath);
}());
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
_sessionExpirySub = ref.listenManual<int>( _sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider, sessionExpiryProvider,
(previous, next) async { (previous, next) async {
@@ -45,8 +68,48 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
); );
} }
Future<void> _ensureMandatoryTtsRequirements() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android || !mounted) {
return;
}
final notifier = ref.read(ttsProvider.notifier);
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!mounted) return;
while (mounted && !ref.read(ttsProvider).batteryOptimizationIgnored) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
title: const Text('Yeu cau bat buoc cho TTS'),
content: const Text(
'Can bat Chay nen va Loai tru toi uu pin de TTS khong bi ngat dot ngot.',
),
actions: [
FilledButton(
onPressed: () async {
await notifier.setBackgroundModeEnabled(true);
await notifier.ensureBatteryOptimizationIgnored();
if (!context.mounted) return;
if (ref.read(ttsProvider).batteryOptimizationIgnored) {
Navigator.of(context).pop();
}
},
child: const Text('Bat ngay'),
),
],
);
},
);
}
}
@override @override
void dispose() { void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close(); _sessionExpirySub?.close();
super.dispose(); super.dispose();
} }
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
} }
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000'; return 'https://reader-api.fevirtus.dev';
} }
return 'http://localhost:8000'; return 'http://localhost:8000';
+34 -1
View File
@@ -2,10 +2,26 @@ import 'package:equatable/equatable.dart';
import 'novel_model.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 { class BookmarkModel extends Equatable {
const BookmarkModel({ const BookmarkModel({
required this.id, required this.id,
required this.novelId, required this.novelId,
this.type = BookmarkType.bookmarked,
this.lastChapterId, this.lastChapterId,
this.lastChapterNumber, this.lastChapterNumber,
this.readChapters = const [], this.readChapters = const [],
@@ -14,6 +30,7 @@ class BookmarkModel extends Equatable {
final String id; final String id;
final String novelId; final String novelId;
final BookmarkType type;
final String? lastChapterId; final String? lastChapterId;
final int? lastChapterNumber; final int? lastChapterNumber;
final List<int> readChapters; final List<int> readChapters;
@@ -28,11 +45,27 @@ class BookmarkModel extends Equatable {
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList() ?? .toList() ??
[], [],
type: () {
final explicitType = BookmarkType.fromString(json['type'] as String?);
if ((json['type'] as String?) != null) {
return explicitType;
}
// Backward-compatible inference when API does not return `type`.
final inferredLastChapter = json['lastChapterNumber'] as int?;
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const <int>[];
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
? BookmarkType.reading
: BookmarkType.bookmarked;
}(),
novel: json['novel'] != null novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>) ? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null, : null,
); );
@override @override
List<Object?> get props => [id, novelId]; List<Object?> get props => [id, novelId, type];
} }
+21 -2
View File
@@ -5,9 +5,12 @@ class ReadingSettings {
this.letterSpacing = 0, this.letterSpacing = 0,
this.fontFamily = 'serif', this.fontFamily = 'serif',
this.themePreset = 'paper', this.themePreset = 'paper',
this.backgroundColorValue = 0xFFFFFEF8,
this.textColorValue = 0xFF111111,
this.horizontalPadding = 20, this.horizontalPadding = 20,
this.paragraphSpacing = 24, this.paragraphSpacing = 24,
this.textAlign = 'justify', this.textAlign = 'left',
this.enableSentenceTapTts = false,
}); });
final double fontSize; final double fontSize;
@@ -15,9 +18,12 @@ class ReadingSettings {
final double letterSpacing; final double letterSpacing;
final String fontFamily; final String fontFamily;
final String themePreset; final String themePreset;
final int backgroundColorValue;
final int textColorValue;
final double horizontalPadding; final double horizontalPadding;
final double paragraphSpacing; final double paragraphSpacing;
final String textAlign; final String textAlign;
final bool enableSentenceTapTts;
ReadingSettings copyWith({ ReadingSettings copyWith({
double? fontSize, double? fontSize,
@@ -25,9 +31,12 @@ class ReadingSettings {
double? letterSpacing, double? letterSpacing,
String? fontFamily, String? fontFamily,
String? themePreset, String? themePreset,
int? backgroundColorValue,
int? textColorValue,
double? horizontalPadding, double? horizontalPadding,
double? paragraphSpacing, double? paragraphSpacing,
String? textAlign, String? textAlign,
bool? enableSentenceTapTts,
}) => }) =>
ReadingSettings( ReadingSettings(
fontSize: fontSize ?? this.fontSize, fontSize: fontSize ?? this.fontSize,
@@ -35,9 +44,12 @@ class ReadingSettings {
letterSpacing: letterSpacing ?? this.letterSpacing, letterSpacing: letterSpacing ?? this.letterSpacing,
fontFamily: fontFamily ?? this.fontFamily, fontFamily: fontFamily ?? this.fontFamily,
themePreset: themePreset ?? this.themePreset, themePreset: themePreset ?? this.themePreset,
backgroundColorValue: backgroundColorValue ?? this.backgroundColorValue,
textColorValue: textColorValue ?? this.textColorValue,
horizontalPadding: horizontalPadding ?? this.horizontalPadding, horizontalPadding: horizontalPadding ?? this.horizontalPadding,
paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing, paragraphSpacing: paragraphSpacing ?? this.paragraphSpacing,
textAlign: textAlign ?? this.textAlign, textAlign: textAlign ?? this.textAlign,
enableSentenceTapTts: enableSentenceTapTts ?? this.enableSentenceTapTts,
); );
factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings( factory ReadingSettings.fromJson(Map<String, dynamic> json) => ReadingSettings(
@@ -46,9 +58,13 @@ class ReadingSettings {
letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0, letterSpacing: (json['letterSpacing'] as num?)?.toDouble() ?? 0,
fontFamily: json['fontFamily'] as String? ?? 'serif', fontFamily: json['fontFamily'] as String? ?? 'serif',
themePreset: json['themePreset'] as String? ?? 'paper', themePreset: json['themePreset'] as String? ?? 'paper',
backgroundColorValue:
(json['backgroundColorValue'] as num?)?.toInt() ?? 0xFFFFFEF8,
textColorValue: (json['textColorValue'] as num?)?.toInt() ?? 0xFF111111,
horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20, horizontalPadding: (json['horizontalPadding'] as num?)?.toDouble() ?? 20,
paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24, paragraphSpacing: (json['paragraphSpacing'] as num?)?.toDouble() ?? 24,
textAlign: json['textAlign'] as String? ?? 'justify', textAlign: json['textAlign'] as String? ?? 'left',
enableSentenceTapTts: json['enableSentenceTapTts'] as bool? ?? false,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@@ -57,8 +73,11 @@ class ReadingSettings {
'letterSpacing': letterSpacing, 'letterSpacing': letterSpacing,
'fontFamily': fontFamily, 'fontFamily': fontFamily,
'themePreset': themePreset, 'themePreset': themePreset,
'backgroundColorValue': backgroundColorValue,
'textColorValue': textColorValue,
'horizontalPadding': horizontalPadding, 'horizontalPadding': horizontalPadding,
'paragraphSpacing': paragraphSpacing, 'paragraphSpacing': paragraphSpacing,
'textAlign': textAlign, 'textAlign': textAlign,
'enableSentenceTapTts': enableSentenceTapTts,
}; };
} }
+42 -2
View File
@@ -8,12 +8,15 @@ class LocalStore {
static const _kLetterSpacing = 'reader_letter_spacing'; static const _kLetterSpacing = 'reader_letter_spacing';
static const _kFontFamily = 'reader_font_family'; static const _kFontFamily = 'reader_font_family';
static const _kThemePreset = 'reader_theme_preset'; static const _kThemePreset = 'reader_theme_preset';
static const _kBackgroundColor = 'reader_background_color';
static const _kTextColor = 'reader_text_color';
static const _kHorizontalPadding = 'reader_horizontal_padding'; static const _kHorizontalPadding = 'reader_horizontal_padding';
static const _kParagraphSpacing = 'reader_paragraph_spacing'; static const _kParagraphSpacing = 'reader_paragraph_spacing';
static const _kTextAlign = 'reader_text_align'; static const _kTextAlign = 'reader_text_align';
static const _kProgressChapterId = 'progress_chapter_id_'; static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_'; static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_'; static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ────────────────────────────────────────────────────── // ── Reading settings ──────────────────────────────────────────────────────
@@ -24,6 +27,8 @@ class LocalStore {
await prefs.setDouble(_kLetterSpacing, settings.letterSpacing); await prefs.setDouble(_kLetterSpacing, settings.letterSpacing);
await prefs.setString(_kFontFamily, settings.fontFamily); await prefs.setString(_kFontFamily, settings.fontFamily);
await prefs.setString(_kThemePreset, settings.themePreset); await prefs.setString(_kThemePreset, settings.themePreset);
await prefs.setInt(_kBackgroundColor, settings.backgroundColorValue);
await prefs.setInt(_kTextColor, settings.textColorValue);
await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding); await prefs.setDouble(_kHorizontalPadding, settings.horizontalPadding);
await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing); await prefs.setDouble(_kParagraphSpacing, settings.paragraphSpacing);
await prefs.setString(_kTextAlign, settings.textAlign); await prefs.setString(_kTextAlign, settings.textAlign);
@@ -32,15 +37,29 @@ class LocalStore {
Future<ReadingSettings?> loadReadingSettings() async { Future<ReadingSettings?> loadReadingSettings() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(_kFontSize)) return null; if (!prefs.containsKey(_kFontSize)) return null;
final themePreset = prefs.getString(_kThemePreset) ?? 'paper';
final fallbackBackground = switch (themePreset) {
'night' => 0xFF101418,
'sepia' => 0xFFF6EAD7,
_ => 0xFFFFFEF8,
};
final fallbackText = switch (themePreset) {
'night' => 0xFFE6EAF2,
'sepia' => 0xFF3B2F23,
_ => 0xFF111111,
};
return ReadingSettings( return ReadingSettings(
fontSize: prefs.getDouble(_kFontSize) ?? 18, fontSize: prefs.getDouble(_kFontSize) ?? 18,
lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8, lineHeight: prefs.getDouble(_kLineHeight) ?? 1.8,
letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0, letterSpacing: prefs.getDouble(_kLetterSpacing) ?? 0,
fontFamily: prefs.getString(_kFontFamily) ?? 'serif', fontFamily: prefs.getString(_kFontFamily) ?? 'serif',
themePreset: prefs.getString(_kThemePreset) ?? 'paper', themePreset: themePreset,
backgroundColorValue: prefs.getInt(_kBackgroundColor) ?? fallbackBackground,
textColorValue: prefs.getInt(_kTextColor) ?? fallbackText,
horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20, horizontalPadding: prefs.getDouble(_kHorizontalPadding) ?? 20,
paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24, paragraphSpacing: prefs.getDouble(_kParagraphSpacing) ?? 24,
textAlign: prefs.getString(_kTextAlign) ?? 'justify', textAlign: prefs.getString(_kTextAlign) ?? 'left',
); );
} }
@@ -68,6 +87,27 @@ class LocalStore {
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0, 'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
}; };
} }
// ── Last route restore (cold start after process reclaim) ───────────────
Future<void> saveLastRoutePath(String path) async {
final normalized = path.trim();
if (normalized.isEmpty || normalized == '/') return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastRoutePath, normalized);
}
Future<String?> loadLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_kLastRoutePath)?.trim();
if (value == null || value.isEmpty) return null;
return value;
}
Future<void> clearLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastRoutePath);
}
} }
final localStoreProvider = Provider<LocalStore>((_) => LocalStore()); final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/bookmark_model.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 '../providers/bookshelf_provider.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
@@ -17,21 +19,30 @@ class BookshelfScreen extends ConsumerWidget {
if (!isAuth) { if (!isAuth) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tủ sách')), body: Column(
body: Center( children: [
child: Column( const MainAppHeader(title: 'Đăng truyện'),
mainAxisSize: MainAxisSize.min, Expanded(
children: [ child: Center(
const Icon(Icons.lock_outline, size: 48), child: Padding(
const SizedBox(height: 12), padding: const EdgeInsets.all(24),
const Text('Vui lòng đăng nhập để xem tủ sách'), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
FilledButton( children: [
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(), const Icon(Icons.lock_outline_rounded, size: 54),
child: const Text('Đăng nhập bằng Google'), const SizedBox(height: 12),
const Text('Vui lòng đăng nhập để xem tủ sách'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
],
),
),
), ),
], ),
), ],
), ),
); );
} }
@@ -39,51 +50,108 @@ class BookshelfScreen extends ConsumerWidget {
final bookshelfAsync = ref.watch(bookshelfProvider); final bookshelfAsync = ref.watch(bookshelfProvider);
return Scaffold( return Scaffold(
appBar: AppBar( body: DefaultTabController(
title: const Text('Tủ sách'), length: 2,
actions: [ child: Column(
IconButton( children: [
icon: const Icon(Icons.refresh), MainAppHeader(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), title: 'Đăng truyện',
), bottom: Container(
], height: 42,
), decoration: BoxDecoration(
body: bookshelfAsync.when( color: const Color(0xFF14B8A6),
loading: () => const Center(child: CircularProgressIndicator()), borderRadius: BorderRadius.circular(0),
error: (e, _) => Center( ),
child: Column( child: TabBar(
mainAxisSize: MainAxisSize.min, indicatorColor: const Color(0xFFF7B500),
children: [ indicatorWeight: 3,
const Icon(Icons.error_outline, size: 48), labelColor: Colors.white,
const SizedBox(height: 8), unselectedLabelColor: Colors.white70,
Text('Lỗi: $e'), dividerColor: Colors.transparent,
TextButton( tabs: const [
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(), Tab(text: 'Đang đọc'),
child: const Text('Thử lại'), Tab(text: 'Đánh dấu'),
],
),
), ),
],
),
),
data: (bookmarks) {
if (bookmarks.isEmpty) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.menu_book_outlined, size: 56),
SizedBox(height: 12),
Text('Chưa có truyện nào trong tủ sách'),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.builder(
itemCount: bookmarks.length,
itemBuilder: (context, index) =>
_BookmarkTile(bookmark: bookmarks[index]),
), ),
Expanded(
child: bookshelfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline_rounded, size: 48),
const SizedBox(height: 8),
Text('Lỗi: $e'),
TextButton(
onPressed: () => ref.read(bookshelfProvider.notifier).fetch(),
child: const Text('Thử lại'),
),
],
),
),
data: (bookmarks) {
final readingItems = ref.watch(readingBookmarksProvider);
final bookmarkedItems = ref.watch(savedBookmarksProvider);
return TabBarView(
children: [
_BookshelfList(
bookmarks: readingItems,
emptyLabel: 'Chưa có truyện đang đọc.',
),
_BookshelfList(
bookmarks: bookmarkedItems,
emptyLabel: 'Chưa có truyện đánh dấu.',
),
],
);
},
),
),
],
),
),
);
}
}
class _BookshelfList extends ConsumerWidget {
const _BookshelfList({required this.bookmarks, required this.emptyLabel});
final List<BookmarkModel> bookmarks;
final String emptyLabel;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (bookmarks.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.menu_book_outlined, size: 56),
const SizedBox(height: 12),
Text(emptyLabel),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(bookshelfProvider.notifier).fetch(),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 24),
itemCount: bookmarks.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final bookmark = bookmarks[index];
return _BookmarkTile(
bookmark: bookmark,
onRemove: () => ref
.read(bookshelfProvider.notifier)
.removeFromShelf(bookmark.novelId, bookmark.type),
); );
}, },
), ),
@@ -91,39 +159,145 @@ class BookshelfScreen extends ConsumerWidget {
} }
} }
class _BookmarkTile extends StatelessWidget { class _BookmarkTile extends ConsumerWidget {
final BookmarkModel bookmark; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final novel = bookmark.novel; final novel = bookmark.novel;
return ListTile( return GestureDetector(
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: novel?.coverUrl != null
? CachedNetworkImage(
imageUrl: novel!.coverUrl!,
width: 44,
height: 60,
fit: BoxFit.cover,
)
: Container(
width: 44,
height: 60,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 20),
),
),
title: Text(
novel?.title ?? bookmark.novelId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: novel?.authorName != null
? Text(novel!.authorName, maxLines: 1, overflow: TextOverflow.ellipsis)
: null,
onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)), onTap: () => context.push(RouteNames.novelDetail(bookmark.novelId)),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel?.coverUrl != null
? CachedNetworkImage(
imageUrl: novel!.coverUrl!,
width: 92,
height: 126,
fit: BoxFit.cover,
)
: Container(
width: 92,
height: 126,
color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book, size: 28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
novel?.title ?? bookmark.novelId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onRemove,
child: const Icon(Icons.close_rounded, size: 20),
),
],
),
const SizedBox(height: 8),
Text(
'Số chương: ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (bookmark.lastChapterNumber != null) ...[
const SizedBox(height: 6),
Text(
'Đang đọc đến: ${bookmark.lastChapterNumber} / ${novel?.totalChapters ?? '--'}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (novel?.authorName != null) ...[
const SizedBox(height: 10),
Text(
novel!.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: () => _openContinueReader(context, ref),
icon: const Icon(Icons.menu_book_rounded),
label: const Text('Đọc 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 { Future<void> toggle(String novelId) async {
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
@@ -44,6 +100,22 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
bool isBookmarked(String novelId) { bool isBookmarked(String novelId) {
return (state.valueOrNull ?? []).any((b) => b.novelId == 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 = final bookshelfProvider =
@@ -51,6 +123,16 @@ final bookshelfProvider =
return BookshelfNotifier(ref); 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 isBookmarkedProvider = Provider.family<bool, String>((ref, novelId) {
final bookshelf = ref.watch(bookshelfProvider); final bookshelf = ref.watch(bookshelfProvider);
return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false; return bookshelf.valueOrNull?.any((b) => b.novelId == novelId) ?? false;
+376 -113
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart'; import '../../../core/models/novel_model.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../providers/home_provider.dart'; import '../providers/home_provider.dart';
class HomeScreen extends ConsumerWidget { class HomeScreen extends ConsumerWidget {
@@ -13,62 +16,67 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final homeAsync = ref.watch(homeProvider); final homeAsync = ref.watch(homeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold( return Scaffold(
appBar: AppBar( backgroundColor: colorScheme.surface,
title: const Text('Reader'), body: Column(
actions: [ children: [
IconButton( const MainAppHeader(),
icon: const Icon(Icons.search), Expanded(
onPressed: () => context.go(RouteNames.search), child: homeAsync.when(
), loading: () => const Center(child: CircularProgressIndicator()),
], error: (e, _) => Center(
), child: Padding(
body: homeAsync.when( padding: const EdgeInsets.all(24),
loading: () => const Center(child: CircularProgressIndicator()), child: Column(
error: (e, _) => Center( mainAxisSize: MainAxisSize.min,
child: Column( children: [
mainAxisSize: MainAxisSize.min, const Icon(Icons.cloud_off_rounded, size: 52),
children: [ const SizedBox(height: 12),
const Icon(Icons.error_outline, size: 48), Text('Không thể tải dữ liệu trang chủ'),
const SizedBox(height: 12), const SizedBox(height: 8),
Text('Lỗi tải dữ liệu', style: Theme.of(context).textTheme.bodyLarge), Text(
Padding( e.toString(),
padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), maxLines: 3,
child: Text( overflow: TextOverflow.ellipsis,
e.toString(), textAlign: TextAlign.center,
textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall,
maxLines: 3, ),
overflow: TextOverflow.ellipsis, const SizedBox(height: 12),
style: Theme.of(context).textTheme.bodySmall, FilledButton(
onPressed: () => ref.invalidate(homeProvider),
child: const Text('Tải lại'),
),
],
),
), ),
), ),
TextButton( data: (data) => RefreshIndicator(
onPressed: () => ref.invalidate(homeProvider), onRefresh: () async => ref.invalidate(homeProvider),
child: const Text('Thử lại'), child: ListView(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 24),
children: [
_HotCarousel(novels: data.hot),
const SizedBox(height: 12),
const _HomeQuickFilters(),
_SectionHeader(
title: 'Truyện mới nhất',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đề cử nổi bật',
onMore: () => context.go('${RouteNames.search}?sort=rating'),
),
_FeatureGrid(novels: data.topRated.take(6).toList()),
const SizedBox(height: 12),
],
),
), ),
], ),
), ),
), ],
data: (data) => RefreshIndicator(
onRefresh: () async => ref.invalidate(homeProvider),
child: ListView(
children: [
_HotCarousel(novels: data.hot),
_SectionHeader(
title: 'Mới cập nhật',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.latest),
_SectionHeader(
title: 'Đánh giá cao',
onMore: () => context.go(RouteNames.search),
),
_NovelHorizontalList(novels: data.topRated),
const SizedBox(height: 16),
],
),
),
), ),
); );
} }
@@ -82,18 +90,82 @@ class _SectionHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 8, 8), padding: const EdgeInsets.fromLTRB(18, 18, 12, 6),
child: Row( child: Row(
children: [ children: [
Text(title, style: Theme.of(context).textTheme.titleMedium), Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(), const Spacer(),
if (onMore != null) if (onMore != null)
TextButton(onPressed: onMore, child: const Text('Xem thêm')), InkWell(
onTap: onMore,
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
children: [
Text(
'Xem thêm',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF14B8A6)),
],
),
),
),
], ],
), ),
); );
} }
class _HomeQuickFilters extends StatelessWidget {
const _HomeQuickFilters();
@override
Widget build(BuildContext context) {
const items = [
(Icons.dashboard_customize_rounded, 'Thể loại'),
(Icons.verified_rounded, 'Hoàn thành'),
(Icons.sell_rounded, 'Miễn phí'),
(Icons.local_fire_department_rounded, 'Truyện hot'),
];
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 8),
child: Row(
children: items
.map(
(item) => Expanded(
child: Column(
children: [
Icon(item.$1, color: const Color(0xFF14B8A6), size: 26),
const SizedBox(height: 6),
Text(
item.$2,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF14B8A6),
),
),
],
),
),
)
.toList(),
),
);
}
}
class _HotCarousel extends StatefulWidget { class _HotCarousel extends StatefulWidget {
final List<NovelModel> novels; final List<NovelModel> novels;
const _HotCarousel({required this.novels}); const _HotCarousel({required this.novels});
@@ -103,10 +175,58 @@ class _HotCarousel extends StatefulWidget {
} }
class _HotCarouselState extends State<_HotCarousel> { class _HotCarouselState extends State<_HotCarousel> {
final PageController _controller = PageController(viewportFraction: 0.85); late PageController _controller;
Timer? _autoSlideTimer;
int _currentPage = 0;
@override
void initState() {
super.initState();
_controller = PageController(viewportFraction: 1);
_startAutoSlide();
}
@override
void reassemble() {
super.reassemble();
_recreateController();
}
void _recreateController() {
final oldController = _controller;
final page = oldController.hasClients
? (oldController.page?.round() ?? _currentPage)
: _currentPage;
_controller = PageController(initialPage: page, viewportFraction: 1);
oldController.dispose();
}
@override
void didUpdateWidget(covariant _HotCarousel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.novels.length != widget.novels.length) {
_startAutoSlide();
}
}
void _startAutoSlide() {
_autoSlideTimer?.cancel();
if (widget.novels.length <= 1) return;
_autoSlideTimer = Timer.periodic(const Duration(seconds: 4), (_) {
if (!mounted || !_controller.hasClients) return;
final nextPage = (_currentPage + 1) % widget.novels.length;
_controller.animateToPage(
nextPage,
duration: const Duration(milliseconds: 360),
curve: Curves.easeInOut,
);
});
}
@override @override
void dispose() { void dispose() {
_autoSlideTimer?.cancel();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -115,17 +235,49 @@ class _HotCarouselState extends State<_HotCarousel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.novels.isEmpty) return const SizedBox.shrink(); if (widget.novels.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
height: 220, height: 260,
child: PageView.builder( child: Column(
controller: _controller, children: [
itemCount: widget.novels.length, Expanded(
itemBuilder: (context, index) { child: Padding(
final novel = widget.novels[index]; padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
return GestureDetector( child: ClipRRect(
onTap: () => context.push(RouteNames.novelDetail(novel.id)), borderRadius: BorderRadius.circular(12),
child: _CarouselCard(novel: novel), child: ClipRect(
); child: PageView.builder(
}, controller: _controller,
itemCount: widget.novels.length,
onPageChanged: (value) => setState(() => _currentPage = value),
itemBuilder: (context, index) {
final novel = widget.novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: _CarouselCard(novel: novel),
);
},
),
),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.novels.length.clamp(0, 5), (index) {
final active = index == _currentPage.clamp(0, 4);
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: active ? 16 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? const Color(0xFF14B8A6) : Colors.white54,
borderRadius: BorderRadius.circular(99),
),
);
}),
),
],
), ),
); );
} }
@@ -137,52 +289,76 @@ class _CarouselCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Stack(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 12), fit: StackFit.expand,
child: ClipRRect( children: [
borderRadius: BorderRadius.circular(12), if (novel.coverUrl != null)
child: Stack( CachedNetworkImage(
fit: StackFit.expand, imageUrl: novel.coverUrl!,
children: [ fit: BoxFit.cover,
if (novel.coverUrl != null) placeholder: (_, imageUrl) => Container(color: Colors.grey[200]),
CachedNetworkImage( errorWidget: (_, imageUrl, error) => Container(color: Colors.grey[300]),
imageUrl: novel.coverUrl!, )
fit: BoxFit.cover, else
placeholder: (_, imageUrl) => Container(color: Colors.grey[200]), Container(color: Theme.of(context).colorScheme.primaryContainer),
errorWidget: (_, imageUrl, error) => Positioned.fill(
Container(color: Colors.grey[300]), child: DecoratedBox(
) decoration: BoxDecoration(
else gradient: LinearGradient(
Container(color: Theme.of(context).colorScheme.primaryContainer), begin: Alignment.topCenter,
Positioned.fill( end: Alignment.bottomCenter,
child: DecoratedBox( colors: [Colors.transparent, Colors.black.withAlpha(180)],
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withAlpha(180)],
),
),
), ),
), ),
Positioned( ),
bottom: 12, ),
left: 12, Positioned(
right: 12, bottom: 12,
child: Text( left: 12,
right: 12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (novel.status.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF22C55E),
borderRadius: BorderRadius.circular(999),
),
child: Text(
novel.status,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(height: 10),
Text(
novel.title, novel.title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 20,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), const SizedBox(height: 4),
], Text(
novel.description?.trim().isNotEmpty == true
? novel.description!.trim()
: novel.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontStyle: FontStyle.italic),
),
],
),
), ),
), ],
); );
} }
} }
@@ -194,9 +370,9 @@ class _NovelHorizontalList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
height: 200, height: 226,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 18),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: novels.length, itemCount: novels.length,
separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12), separatorBuilder: (_, separatorIndex) => const SizedBox(width: 12),
@@ -205,32 +381,45 @@ class _NovelHorizontalList extends StatelessWidget {
return GestureDetector( return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)), onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: SizedBox( child: SizedBox(
width: 110, width: 122,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null child: novel.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: novel.coverUrl!, imageUrl: novel.coverUrl!,
width: 110, width: 122,
height: 150, height: 155,
fit: BoxFit.cover, fit: BoxFit.cover,
) )
: Container( : Container(
width: 110, width: 122,
height: 150, height: 155,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.menu_book), child: const Icon(Icons.menu_book),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 6),
Flexible(
child: Text(
novel.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 3),
Text( Text(
novel.title, '${novel.totalChapters} chương',
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: const Color(0xFF58D68D),
),
), ),
], ],
), ),
@@ -241,3 +430,77 @@ class _NovelHorizontalList extends StatelessWidget {
); );
} }
} }
class _FeatureGrid extends StatelessWidget {
const _FeatureGrid({required this.novels});
final List<NovelModel> novels;
@override
Widget build(BuildContext context) {
if (novels.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 0),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: novels.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 18,
crossAxisSpacing: 14,
childAspectRatio: 0.74,
),
itemBuilder: (context, index) {
final novel = novels[index];
return GestureDetector(
onTap: () => context.push(RouteNames.novelDetail(novel.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: novel.coverUrl != null
? CachedNetworkImage(
imageUrl: novel.coverUrl!,
width: double.infinity,
fit: BoxFit.cover,
)
: Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: const Center(child: Icon(Icons.menu_book_rounded)),
),
),
),
const SizedBox(height: 8),
Text(
novel.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${novel.totalChapters} Chương',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF58D68D),
),
),
Text(
'${novel.bookmarkCount > 0 ? novel.bookmarkCount : novel.views} ${novel.bookmarkCount > 0 ? 'Đề cử/tuần' : 'Lượt xem'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: const Color(0xFF1677FF),
),
),
],
),
);
},
),
);
}
}
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/models/chapter_model.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ────────────────────────────────────────────────────────── // ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams { class BrowseParams {
@@ -28,11 +26,11 @@ class BrowseParams {
if (raw == null || raw.isEmpty) return null; if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) { switch (raw.toLowerCase()) {
case 'ongoing': case 'ongoing':
return 'Đang ra'; return 'ONGOING';
case 'completed': case 'completed':
return 'Hoàn thành'; return 'COMPLETED';
case 'hiatus': case 'hiatus':
return 'Tạm ngưng'; return 'HIATUS';
default: default:
return raw; return raw;
} }
@@ -189,77 +187,51 @@ final novelDetailProvider =
// ─── Chapter List ───────────────────────────────────────────────────────────── // ─── 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 = final chapterListProvider =
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async { FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async { Future<List<ChapterListItem>> fetchAllChapters(String idOrSlug) async {
final res = await client.dio.get( const limit = 500;
'/api/truyen/$idOrSlug/chapters', var page = 1;
queryParameters: { var totalPages = 1;
'page': query.page, final items = <ChapterListItem>[];
'limit': chapterPageSize,
}, while (page <= totalPages) {
); final res = await client.dio.get(
return res.data as Map<String, dynamic>; '/api/truyen/$idOrSlug/chapters',
queryParameters: {'page': page, 'limit': limit},
);
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;
}
return items;
} }
var data = await fetchChapterPage(query.novelId); try {
var chapters = data['chapters'] as List? ?? const []; return await fetchAllChapters(novelId);
} catch (_) {
// Backend stores chapters by novel id in MongoDB; if route opened by slug, // 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. // first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try { 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 novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?; final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) { if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != novelId) {
data = await fetchChapterPage(canonicalId); return await fetchAllChapters(canonicalId);
chapters = data['chapters'] as List? ?? const [];
} }
} catch (_) { } catch (_) {
// Keep original empty list when fallback resolution fails. // 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,
);
}); });
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../shared/widgets/main_app_header.dart';
import '../../auth/providers/auth_provider.dart'; import '../../auth/providers/auth_provider.dart';
import '../../bookshelf/providers/bookshelf_provider.dart'; import '../../bookshelf/providers/bookshelf_provider.dart';
@@ -22,151 +23,180 @@ class ProfileScreen extends ConsumerWidget {
: ''; : '';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Tài khoản')), body: Column(
body: switch (authState) { children: [
AuthAuthenticated(:final user) => SingleChildScrollView( const MainAppHeader(title: 'Trang cá nhân', showGenresShortcut: false),
padding: const EdgeInsets.all(16), Expanded(
child: Column( child: switch (authState) {
children: [ AuthAuthenticated(:final user) => SingleChildScrollView(
// User Avatar & Basic Info padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
CircleAvatar( Container(
radius: 40, padding: const EdgeInsets.all(14),
backgroundImage: decoration: BoxDecoration(
user.image != null ? NetworkImage(user.image!) : null, color: Theme.of(context).colorScheme.surfaceContainerLow,
child: user.image == null borderRadius: BorderRadius.circular(22),
? Text( ),
displayName[0].toUpperCase(), child: Row(
style: crossAxisAlignment: CrossAxisAlignment.start,
Theme.of(context).textTheme.headlineMedium, children: [
) CircleAvatar(
: null, radius: 34,
backgroundImage:
user.image != null ? NetworkImage(user.image!) : null,
child: user.image == null
? Text(
displayName.isNotEmpty ? displayName[0].toUpperCase() : 'U',
style: Theme.of(context).textTheme.headlineMedium,
)
: null,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
user.role.toLowerCase(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
_AccountStatRow(
icon: Icons.auto_awesome,
label: 'Tiên Thạch: 0.00 TT',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.diamond,
label: 'Linh Phiếu: 0 LP',
),
const SizedBox(height: 4),
_AccountStatRow(
icon: Icons.local_activity,
label: 'Ngọc Phiếu: $bookmarkedCount',
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.workspace_premium_rounded),
label: const Text('Thêm Tiên Thạch'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF14B8A6),
foregroundColor: Colors.white,
),
),
],
),
),
],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 18),
Text( _ProfileMenuTile(
displayName, title: 'Chỉnh sửa thông tin',
style: Theme.of(context).textTheme.titleLarge, onTap: () => context.push(RouteNames.settings),
textAlign: TextAlign.center,
), ),
const SizedBox(height: 4), _ProfileMenuTile(
Text( title: 'Lịch sử giao dịch',
user.email, onTap: () {},
style: Theme.of(context).textTheme.bodyMedium, ),
textAlign: TextAlign.center, _ProfileMenuTile(
title: 'Liên hệ, báo lỗi',
onTap: () {},
),
_ProfileMenuTile(
title: 'Điều khoản dịch vụ',
onTap: () {},
),
_ProfileMenuTile(
title: 'Xóa tài khoản',
onTap: () {},
),
_ProfileMenuTile(
title: 'Đăng xuất',
onTap: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
), ),
], ],
), ),
), ),
const SizedBox(height: 24), AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
// Stats Cards child: Padding(
Row( padding: const EdgeInsets.all(16),
children: [ child: Column(
Expanded( mainAxisSize: MainAxisSize.min,
child: _buildStatCard( children: [
context: context, FilledButton(
label: 'Sách Đánh Dấu', onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
count: bookmarkedCount, child: const Text('Đăng nhập bằng Google'),
), ),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
), ),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
context: context,
label: 'Đang Đọc',
count: bookmarkedCount,
),
),
],
),
const SizedBox(height: 24),
// Settings Button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Cài Đặt Đọc'),
), ),
), ),
const SizedBox(height: 12), _ => const Center(child: CircularProgressIndicator()),
},
// Logout Button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).signOut();
if (context.mounted) context.go(RouteNames.home);
},
icon: const Icon(Icons.logout),
label: const Text('Đăng Xuất'),
),
),
],
),
),
AuthError(:final message) => Center(child: Text(message)),
AuthUnauthenticated() => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton(
onPressed: () => ref.read(authProvider.notifier).signInWithGoogle(),
child: const Text('Đăng nhập bằng Google'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push(RouteNames.settings),
icon: const Icon(Icons.tune),
label: const Text('Mở Cài Đặt Đọc'),
),
],
),
),
),
_ => const Center(child: CircularProgressIndicator()),
},
);
}
Widget _buildStatCard({
required BuildContext context,
required String label,
required int count,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
count.toString(),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
), ),
], ],
), ),
); );
} }
} }
class _AccountStatRow extends StatelessWidget {
const _AccountStatRow({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF58D68D)),
const SizedBox(width: 8),
Expanded(child: Text(label, style: Theme.of(context).textTheme.titleMedium)),
],
);
}
}
class _ProfileMenuTile extends StatelessWidget {
const _ProfileMenuTile({required this.title, required this.onTap});
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(14),
),
child: ListTile(
title: Text(title),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: onTap,
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
required this.content, required this.content,
this.contentKey, this.contentKey,
this.title, this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true, this.includeTitleOnStart = true,
this.resolveStartParagraphIndex, this.resolveStartParagraphIndex,
this.onStarted, this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content; final String content;
final String? contentKey; final String? contentKey;
final String? title; final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart; final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex; final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted; final VoidCallback? onStarted;
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return; return;
} }
notifier.clearPendingAutoStartChapter();
unawaited( unawaited(
notifier.startReading( notifier.startReading(
content, content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(), startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey, contentKey: contentKey,
title: title, title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart, includeTitle: includeTitleOnStart,
), ),
); );
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart'; import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart'; import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart'; import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// ─── Chapter content ───────────────────────────────────────────────────────── // ─── Chapter content ─────────────────────────────────────────────────────────
@@ -94,23 +95,45 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget) // Also notify server (fire and forget)
try { try {
final client = _ref.read(apiClientProvider); final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: { final res = await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId, 'novelId': _novelId,
'chapterId': chapterId, 'chapterId': chapterId,
'chapterNumber': chapterNumber, 'chapterNumber': chapterNumber,
'progress': offset, 'progress': offset,
}); });
final data = res.data;
Map<String, dynamic>? bookmarkJson;
if (data is Map<String, dynamic>) {
final bookmark = data['bookmark'];
if (bookmark is Map<String, dynamic>) {
bookmarkJson = bookmark;
}
}
_ref.read(bookshelfProvider.notifier).syncProgress(
novelId: _novelId!,
chapterId: chapterId,
chapterNumber: chapterNumber,
serverBookmark: bookmarkJson,
);
} catch (_) {} } catch (_) {}
} }
DateTime? _lastUpdate; Timer? _debounceTimer;
Future<void> _debounceUpdate(double offset) async { void _debounceUpdate(double offset) {
final now = DateTime.now(); _debounceTimer?.cancel();
if (_lastUpdate != null && now.difference(_lastUpdate!).inSeconds < 3) return; _debounceTimer = Timer(const Duration(seconds: 3), () {
_lastUpdate = now; if (state != null) {
if (state != null) { unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, offset));
await _persistProgress(state!.chapterId, state!.chapterNumber, offset); }
} });
}
@override
void dispose() {
_debounceTimer?.cancel();
super.dispose();
} }
} }
@@ -139,6 +162,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
final localStore = _ref.read(localStoreProvider); final localStore = _ref.read(localStoreProvider);
await localStore.saveReadingSettings(settings); await localStore.saveReadingSettings(settings);
} }
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
await update(state.copyWith(enableSentenceTapTts: enabled));
}
} }
final readingSettingsProvider = final readingSettingsProvider =
@@ -0,0 +1,133 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused, stopped }
class TtsState {
final TtsStatus status;
final int currentSentenceIndex;
final List<String> sentences;
final double speechRate;
final double volume;
final double pitch;
final String? currentLanguage;
const TtsState({
this.status = TtsStatus.idle,
this.currentSentenceIndex = 0,
this.sentences = const [],
this.speechRate = 0.5,
this.volume = 1.0,
this.pitch = 1.0,
this.currentLanguage,
});
TtsState copyWith({
TtsStatus? status,
int? currentSentenceIndex,
List<String>? sentences,
double? speechRate,
double? volume,
double? pitch,
String? currentLanguage,
}) {
return TtsState(
status: status ?? this.status,
currentSentenceIndex: currentSentenceIndex ?? this.currentSentenceIndex,
sentences: sentences ?? this.sentences,
speechRate: speechRate ?? this.speechRate,
volume: volume ?? this.volume,
pitch: pitch ?? this.pitch,
currentLanguage: currentLanguage ?? this.currentLanguage,
);
}
}
class TtsNotifier extends Notifier<TtsState> {
late FlutterTts _tts;
@override
TtsState build() {
_tts = FlutterTts();
_initTts();
ref.onDispose(() async {
await _tts.stop();
});
return const TtsState();
}
Future<void> _initTts() async {
await _tts.setLanguage('vi-VN');
await _tts.setSpeechRate(state.speechRate);
await _tts.setVolume(state.volume);
await _tts.setPitch(state.pitch);
// Do NOT use awaitSpeakCompletion(true) — it blocks the Dart↔native channel
// between sentences, causing Android TTS service to disconnect.
await _tts.awaitSpeakCompletion(false);
_tts.setCompletionHandler(_onSentenceComplete);
_tts.setCancelHandler(() {
if (state.status == TtsStatus.playing) {
state = state.copyWith(status: TtsStatus.stopped);
}
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.stopped);
});
}
void _onSentenceComplete() {
if (state.status != TtsStatus.playing) return;
final nextIndex = state.currentSentenceIndex + 1;
if (nextIndex < state.sentences.length) {
state = state.copyWith(currentSentenceIndex: nextIndex);
_speakCurrent();
} else {
state = state.copyWith(
status: TtsStatus.stopped, currentSentenceIndex: 0);
}
}
Future<void> _speakCurrent() async {
if (state.sentences.isEmpty) return;
if (state.status != TtsStatus.playing) return;
final sentence = state.sentences[state.currentSentenceIndex];
await _tts.speak(sentence);
}
Future<void> play(List<String> sentences) async {
await _tts.stop();
state = state.copyWith(
sentences: sentences,
currentSentenceIndex: 0,
status: TtsStatus.playing,
);
await _speakCurrent();
}
Future<void> pause() async {
state = state.copyWith(status: TtsStatus.paused);
await _tts.pause();
}
Future<void> resume() async {
state = state.copyWith(status: TtsStatus.playing);
await _speakCurrent();
}
Future<void> stop() async {
state = state.copyWith(status: TtsStatus.stopped, currentSentenceIndex: 0);
await _tts.stop();
}
Future<void> setSpeechRate(double rate) async {
await _tts.setSpeechRate(rate);
state = state.copyWith(speechRate: rate);
}
}
final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(TtsNotifier.new);
+119 -22
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused } enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.9; const double kTtsBaseSpeechRate = 0.9;
@@ -155,6 +157,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
int _pendingFallbackIndex = -1; int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false; bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false; bool _hasPromptedNotificationSettings = false;
bool _androidFallbackReady = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid; bool get _useNativeAndroidMediaService => Platform.isAndroid;
@@ -258,6 +261,22 @@ class TtsNotifier extends StateNotifier<TtsState> {
await _mediaChannel.invokeMethod<void>('openNotificationSettings'); await _mediaChannel.invokeMethod<void>('openNotificationSettings');
} }
Future<void> refreshNativeSnapshot() async {
if (!_useNativeAndroidMediaService) return;
if (!_initialized) {
await (_initFuture ?? _init());
return;
}
try {
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
} catch (_) {
// Ignore snapshot pull errors; event stream updates will continue.
}
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async { Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices; final dynamic voicesRaw = await _tts.getVoices;
@@ -315,6 +334,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) { void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event); _applyAndroidSnapshot(event);
} }
@@ -372,6 +458,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
// Keep natural sentence flow while removing symbols that are usually read out noisily. // Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw final cleaned = raw
.replaceAll(RegExp(r'["“”]'), ' ')
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ') .replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ') .replaceAll(RegExp(r'\s+'), ' ')
.trim(); .trim();
@@ -566,12 +653,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
int? startCharOffset, int? startCharOffset,
String? contentKey, String? contentKey,
String? title, String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true, bool includeTitle = true,
}) async { }) async {
if (!_initialized) { if (!_initialized) {
await (_initFuture ?? _init()); await (_initFuture ?? _init());
} }
// A direct start request (tap sentence/play button) should win over any
// queued chapter auto-start from previous navigation/completion events.
state = state.copyWith(clearPendingAutoStartChapterId: true);
_segments = _buildSegments( _segments = _buildSegments(
content, content,
title: title, title: title,
@@ -614,33 +708,36 @@ class TtsNotifier extends StateNotifier<TtsState> {
contentKey: contentKey, contentKey: contentKey,
); );
await _mediaChannel.invokeMethod<void>('startReading', { try {
'contentKey': contentKey, await _mediaChannel.invokeMethod<void>('startReading', {
'title': title, 'content': content,
'startIndex': validIndex, 'contentKey': contentKey,
'speed': state.speed, 'title': title,
'language': state.language, 'nextChapterId': nextChapterId,
'voiceName': state.voiceName, 'chapterNumber': chapterNumber,
'backgroundModeEnabled': state.backgroundModeEnabled, 'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
'segments': _segments.map((segment) => segment.toMap()).toList(), 'startIndex': validIndex,
}); 'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'includeTitle': includeTitle,
});
} on PlatformException {
await _startFallbackReading(
validIndex: validIndex,
selectedSegment: selectedSegment,
contentKey: contentKey,
);
}
return; return;
} }
final sessionId = await _interruptFallbackPlayback(); await _startFallbackReading(
validIndex: validIndex,
state = state.copyWith( selectedSegment: selectedSegment,
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey, contentKey: contentKey,
); );
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
} }
Future<int> _interruptFallbackPlayback() async { Future<int> _interruptFallbackPlayback() async {
@@ -1,25 +1,48 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart'; import '../../../app/router/route_names.dart';
import '../../../core/storage/local_store.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@override @override
State<SplashScreen> createState() => _SplashScreenState(); ConsumerState<SplashScreen> createState() => _SplashScreenState();
} }
class _SplashScreenState extends State<SplashScreen> { class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _redirectTimer; Timer? _redirectTimer;
bool _isRestorableRoute(String path) {
if (path.isEmpty || path == RouteNames.splash) return false;
return path == RouteNames.home ||
path == RouteNames.login ||
path == RouteNames.search ||
path.startsWith('${RouteNames.search}?') ||
path == RouteNames.genres ||
path == RouteNames.bookshelf ||
path == RouteNames.profile ||
path == RouteNames.settings ||
path.startsWith('/novel/') ||
path.startsWith('/reader/') ||
path.startsWith('/comments/');
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () { _redirectTimer = Timer(const Duration(milliseconds: 700), () async {
if (!mounted) return; if (!mounted) return;
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
if (!mounted) return;
if (lastPath != null && _isRestorableRoute(lastPath)) {
context.go(lastPath);
return;
}
context.go(RouteNames.home); context.go(RouteNames.home);
}); });
} }
+108 -30
View File
@@ -8,44 +8,122 @@ class AppShell extends StatelessWidget {
final Widget child; final Widget child;
int _indexForLocation(String location) { String _tabForLocation(String location) {
if (location.startsWith(RouteNames.search)) return 1; if (location.startsWith(RouteNames.bookshelf)) return RouteNames.bookshelf;
if (location.startsWith(RouteNames.bookshelf)) return 2; if (location.startsWith(RouteNames.genres)) return RouteNames.genres;
if (location.startsWith(RouteNames.genres)) return 3; if (location.startsWith(RouteNames.profile)) return RouteNames.profile;
if (location.startsWith(RouteNames.profile)) return 4; return RouteNames.home;
return 0;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final selectedIndex = _indexForLocation(location); final selectedTab = _tabForLocation(location);
return Scaffold( return Scaffold(
body: child, body: child,
bottomNavigationBar: NavigationBar( bottomNavigationBar: Container(
selectedIndex: selectedIndex, decoration: BoxDecoration(
onDestinationSelected: (index) { color: colorScheme.surface,
switch (index) { border: Border(
case 0: top: BorderSide(color: colorScheme.outlineVariant.withAlpha(80)),
context.go(RouteNames.home); ),
case 1: ),
context.go(RouteNames.search); child: SafeArea(
case 2: top: false,
context.go(RouteNames.bookshelf); child: Padding(
case 3: padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
context.go(RouteNames.genres); child: Row(
case 4: children: [
context.go(RouteNames.profile); _ShellNavItem(
} icon: Icons.home_rounded,
}, label: 'Trang chủ',
destinations: const [ selected: selectedTab == RouteNames.home,
NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'), onTap: () => context.go(RouteNames.home),
NavigationDestination(icon: Icon(Icons.search), label: 'Tim kiem'), ),
NavigationDestination(icon: Icon(Icons.bookmark_border), label: 'Tu sach'), _ShellNavItem(
NavigationDestination(icon: Icon(Icons.category_outlined), label: 'The loai'), icon: Icons.layers_rounded,
NavigationDestination(icon: Icon(Icons.person_outline), label: 'Tai khoan'), label: 'Tủ sách',
], selected: selectedTab == RouteNames.bookshelf,
onTap: () => context.go(RouteNames.bookshelf),
),
_ShellNavItem(
icon: Icons.category_rounded,
label: 'Thể loại',
selected: selectedTab == RouteNames.genres,
onTap: () => context.go(RouteNames.genres),
),
_ShellNavItem(
icon: Icons.person_rounded,
label: 'Tài khoản',
selected: selectedTab == RouteNames.profile,
onTap: () => context.go(RouteNames.profile),
),
],
),
),
),
),
);
}
}
class _ShellNavItem extends StatelessWidget {
const _ShellNavItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final activeColor = const Color(0xFF14B8A6);
final inactiveColor = colorScheme.onSurfaceVariant;
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: selected ? activeColor.withAlpha(28) : Colors.transparent,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 22,
color: selected ? activeColor : inactiveColor,
),
),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: selected ? const Color(0xFFF7B500) : inactiveColor,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
],
),
),
), ),
); );
} }
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router/route_names.dart';
class MainAppHeader extends StatelessWidget {
const MainAppHeader({
super.key,
this.title = 'Đăng truyện',
this.showSearch = true,
this.showGenresShortcut = true,
this.bottom,
});
final String title;
final bool showSearch;
final bool showGenresShortcut;
final Widget? bottom;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 12),
decoration: BoxDecoration(
color: colorScheme.surface.withAlpha(245),
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant.withAlpha(90)),
),
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
GestureDetector(
onTap: () => context.go(RouteNames.home),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 34,
height: 34,
child: Image.asset(
'assets/app_icon.png',
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.menu_book_rounded,
color: theme.colorScheme.primary,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
"Virtus's Reader",
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: const Color(0xFF15B8A6),
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
if (showSearch)
IconButton(
tooltip: 'Tìm kiếm',
visualDensity: VisualDensity.compact,
onPressed: () => context.go(RouteNames.search),
icon: const Icon(Icons.search_rounded),
color: const Color(0xFF15B8A6),
),
],
),
if (bottom != null) ...[
const SizedBox(height: 12),
bottom!,
],
],
),
),
);
}
}
+4 -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+2 version: 1.0.3+4
environment: environment:
sdk: ^3.11.3 sdk: ^3.11.3
@@ -78,6 +78,9 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true uses-material-design: true
assets:
- assets/app_icon.png
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg