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