9 Commits

Author SHA1 Message Date
virtus 583a41879f feat: Update app branding and icon assets for Virtus's Reader
Build Android AAB / build-aab (push) Successful in 12m2s
Build Android APK / build-apk (push) Successful in 11m58s
2026-04-16 02:29:03 +07:00
virtus 1256475bf9 feat: Enhance search functionality with initial query parameters and infinite scrolling 2026-04-16 02:08:04 +07:00
virtus c892928ff8 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:57:21 +07:00
virtus 76edaa25a4 feat: Implement native Android MediaSession and foreground service for TTS playback
- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
2026-04-10 18:56:36 +07:00
virtus 2d41121b84 feat: Remove specific Java home configuration from gradle.properties
Build Android AAB / build-aab (push) Successful in 11m41s
Build Android APK / build-apk (push) Successful in 13m58s
2026-04-10 16:11:08 +07:00
virtus 1e8c19e5c9 feat: Enhance Android release signing process with improved key alias resolution and error handling
Build Android APK / build-apk (push) Failing after 2m44s
Build Android AAB / build-aab (push) Failing after 2m55s
2026-04-10 13:11:48 +07:00
virtus 183a0acabb feat: Improve AAB SHA-1 verification by enhancing parsing logic and adding error handling 2026-04-10 11:58:35 +07:00
virtus 62ca390691 feat: Add workflow for building Android AAB with release tagging and signing verification 2026-04-10 11:43:32 +07:00
virtus 781b2004e5 feat: Normalize APK SHA-1 comparison in signing verification step
Build Android APK / build-apk (push) Successful in 12m0s
2026-04-08 19:45:24 +07:00
47 changed files with 2255 additions and 337 deletions
+236
View File
@@ -0,0 +1,236 @@
name: Build Android AAB
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag (example: v1.0.10)"
required: false
jobs:
build-aab:
runs-on: ubuntu-latest
env:
BASE_URL: ${{ secrets.BASE_URL }}
GOOGLE_SERVER_CLIENT_ID: ${{ secrets.GOOGLE_SERVER_CLIENT_ID }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
TOKEN: ${{ secrets.TOKEN }}
RELEASE_TAG: ""
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
EXPECTED_ANDROID_SHA1: ${{ secrets.EXPECTED_ANDROID_SHA1 }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
set +e
yes | sdkmanager --licenses >/dev/null 2>&1
LICENSE_EXIT=$?
set -e
if [ "$LICENSE_EXIT" -ne 0 ] && [ "$LICENSE_EXIT" -ne 141 ]; then
echo "sdkmanager --licenses failed with exit code $LICENSE_EXIT"
exit "$LICENSE_EXIT"
fi
- name: Show Flutter and Dart version
run: |
flutter --version
flutter doctor -v
- name: Install dependencies
run: flutter pub get
- name: Install release tooling
run: sudo apt-get update && sudo apt-get install -y jq
- name: Resolve release tag input
run: |
RESOLVED_TAG=""
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ -n "${INPUT_RELEASE_TAG:-}" ]; then
RESOLVED_TAG="${INPUT_RELEASE_TAG}"
elif [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then
RESOLVED_TAG="${GITHUB_REF#refs/tags/}"
fi
echo "RELEASE_TAG=${RESOLVED_TAG}" >> "$GITHUB_ENV"
echo "Resolved RELEASE_TAG=${RESOLVED_TAG:-<empty>}"
- name: Prepare Android release signing
run: |
if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then
echo "Preparing release keystore from secrets"
if ! python3 -c "import base64,os,re,sys; s=os.environ.get('ANDROID_KEYSTORE_BASE64',''); s=s.strip().strip(chr(34)).strip(chr(39)); s=s.replace('\\n',''); s=re.sub(r'^data:[^,]*,','',s); s=re.sub(r'\\s+','',s); s=s + ('=' * (-len(s) % 4)); (print('ANDROID_KEYSTORE_BASE64 is empty after normalization'), sys.exit(1)) if not s else None; d=base64.b64decode(s, validate=False); open('android/app/release.keystore','wb').write(d); print('Decoded keystore bytes:', len(d))"
then
echo "Keystore decoding failed"
exit 1
fi
if [ ! -s android/app/release.keystore ]; then
echo "Decoded keystore file is empty"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" >/dev/null 2>&1; then
echo "Decoded keystore is invalid or ANDROID_KEYSTORE_PASSWORD is incorrect"
exit 1
fi
RESOLVED_KEY_ALIAS="${ANDROID_KEY_ALIAS}"
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
RESOLVED_KEY_ALIAS=$(keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | awk -F': ' '/Alias name:/{print $2; exit}')
fi
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
echo "Could not resolve key alias from keystore"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" -alias "$RESOLVED_KEY_ALIAS" >/dev/null 2>&1; then
echo "Configured key alias does not exist in keystore: $RESOLVED_KEY_ALIAS"
keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | sed -n 's/^Alias name: /Available alias: /p'
exit 1
fi
echo "Using keystore alias: $RESOLVED_KEY_ALIAS"
{
echo "storeFile=release.keystore"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${RESOLVED_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > android/key.properties
else
echo "Release signing secrets are required for tagged release builds."
echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD"
echo "Optional: ANDROID_KEY_ALIAS (auto-detected if omitted)"
exit 1
fi
- name: Build release AAB
run: |
BASE_URL_VALUE="${BASE_URL:-http://127.0.0.1:8000}"
FLUTTER_CMD=(
flutter build appbundle --release
--dart-define=BASE_URL=${BASE_URL_VALUE}
)
if [ -n "${GOOGLE_SERVER_CLIENT_ID}" ]; then
FLUTTER_CMD+=(--dart-define=GOOGLE_SERVER_CLIENT_ID=${GOOGLE_SERVER_CLIENT_ID})
fi
if [ -n "${GOOGLE_CLIENT_ID}" ]; then
FLUTTER_CMD+=(--dart-define=GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID})
fi
"${FLUTTER_CMD[@]}"
- name: Verify AAB signing certificate
run: |
AAB_PATH="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$AAB_PATH" ]; then
echo "AAB not found at $AAB_PATH"
exit 1
fi
CERT_OUTPUT="$(keytool -printcert -jarfile "$AAB_PATH")"
echo "$CERT_OUTPUT"
if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then
AAB_SHA1=$(echo "$CERT_OUTPUT" | awk -F'SHA1:' '/SHA1:/{print $2; exit}')
AAB_SHA1_NORMALIZED=$(echo "$AAB_SHA1" | tr -cd '[:xdigit:]' | tr '[:lower:]' '[:upper:]')
EXPECTED_SHA1_NORMALIZED=$(echo "${EXPECTED_ANDROID_SHA1}" | tr -cd '[:xdigit:]' | tr '[:lower:]' '[:upper:]')
if [ -z "$AAB_SHA1_NORMALIZED" ]; then
echo "Could not parse SHA-1 from keytool output"
exit 1
fi
if [ "$AAB_SHA1_NORMALIZED" != "$EXPECTED_SHA1_NORMALIZED" ]; then
echo "AAB SHA-1 mismatch"
echo "Expected (normalized): $EXPECTED_SHA1_NORMALIZED"
echo "Actual (normalized): $AAB_SHA1_NORMALIZED"
echo "Tip: update EXPECTED_ANDROID_SHA1 to this signer if this is your intended upload key"
exit 1
fi
fi
- name: Create or update Gitea release and upload AAB
run: |
if [ -z "${RELEASE_TAG}" ]; then
echo "No release_tag provided. Build completed without creating a release."
exit 0
fi
if [ -z "${TOKEN}" ]; then
echo "Missing required secret: TOKEN"
exit 1
fi
AAB_PATH="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$AAB_PATH" ]; then
echo "AAB not found at $AAB_PATH"
exit 1
fi
OWNER_REPO="${GITHUB_REPOSITORY}"
OWNER="${OWNER_REPO%%/*}"
REPO="${OWNER_REPO##*/}"
TAG="${RELEASE_TAG}"
API_BASE="${GITHUB_SERVER_URL}/api/v1"
RELEASE_JSON=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
|| true)
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ]; then
RELEASE_JSON=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
fi
if [ -z "$RELEASE_ID" ]; then
echo "Could not resolve release ID for tag ${TAG}"
echo "$RELEASE_JSON"
exit 1
fi
curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${AAB_PATH}" \
"${API_BASE}/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=reader-app-${TAG}.aab"
+29 -8
View File
@@ -84,7 +84,7 @@ jobs:
- name: Prepare Android release signing
run: |
if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_ALIAS}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then
if [ -n "${ANDROID_KEYSTORE_BASE64}" ] && [ -n "${ANDROID_KEYSTORE_PASSWORD}" ] && [ -n "${ANDROID_KEY_PASSWORD}" ]; then
echo "Preparing release keystore from secrets"
if ! python3 -c "import base64,os,re,sys; s=os.environ.get('ANDROID_KEYSTORE_BASE64',''); s=s.strip().strip(chr(34)).strip(chr(39)); s=s.replace('\\n',''); s=re.sub(r'^data:[^,]*,','',s); s=re.sub(r'\\s+','',s); s=s + ('=' * (-len(s) % 4)); (print('ANDROID_KEYSTORE_BASE64 is empty after normalization'), sys.exit(1)) if not s else None; d=base64.b64decode(s, validate=False); open('android/app/release.keystore','wb').write(d); print('Decoded keystore bytes:', len(d))"
@@ -103,15 +103,34 @@ jobs:
exit 1
fi
RESOLVED_KEY_ALIAS="${ANDROID_KEY_ALIAS}"
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
RESOLVED_KEY_ALIAS=$(keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | awk -F': ' '/Alias name:/{print $2; exit}')
fi
if [ -z "$RESOLVED_KEY_ALIAS" ]; then
echo "Could not resolve key alias from keystore"
exit 1
fi
if ! keytool -list -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" -alias "$RESOLVED_KEY_ALIAS" >/dev/null 2>&1; then
echo "Configured key alias does not exist in keystore: $RESOLVED_KEY_ALIAS"
keytool -list -v -keystore android/app/release.keystore -storepass "${ANDROID_KEYSTORE_PASSWORD}" | sed -n 's/^Alias name: /Available alias: /p'
exit 1
fi
echo "Using keystore alias: $RESOLVED_KEY_ALIAS"
{
echo "storeFile=release.keystore"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${ANDROID_KEY_ALIAS}"
echo "keyAlias=${RESOLVED_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > android/key.properties
else
echo "Release signing secrets are required for tagged release builds."
echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD"
echo "Please configure: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD"
echo "Optional: ANDROID_KEY_ALIAS (auto-detected if omitted)"
exit 1
fi
@@ -153,13 +172,15 @@ jobs:
echo "$CERT_OUTPUT"
if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then
APK_SHA1=$(echo "$CERT_OUTPUT" | sed -n 's/.*certificate SHA-1 digest: //p' | head -n1 | tr '[:lower:]' '[:upper:]')
EXPECTED_SHA1_UPPER=$(echo "${EXPECTED_ANDROID_SHA1}" | tr '[:lower:]' '[:upper:]')
APK_SHA1=$(echo "$CERT_OUTPUT" | sed -n 's/.*certificate SHA-1 digest: //p' | head -n1)
APK_SHA1_NORMALIZED=$(echo "$APK_SHA1" | tr -d '[:space:]:-' | tr '[:lower:]' '[:upper:]')
EXPECTED_SHA1_NORMALIZED=$(echo "${EXPECTED_ANDROID_SHA1}" | tr -d '[:space:]:-' | tr '[:lower:]' '[:upper:]')
if [ "$APK_SHA1" != "$EXPECTED_SHA1_UPPER" ]; then
if [ "$APK_SHA1_NORMALIZED" != "$EXPECTED_SHA1_NORMALIZED" ]; then
echo "APK SHA-1 mismatch"
echo "Expected: $EXPECTED_SHA1_UPPER"
echo "Actual : $APK_SHA1"
echo "Expected (normalized): $EXPECTED_SHA1_NORMALIZED"
echo "Actual (normalized): $APK_SHA1_NORMALIZED"
echo "Tip: update EXPECTED_ANDROID_SHA1 to this signer if this is your intended release key"
exit 1
fi
fi
+6 -1
View File
@@ -4,6 +4,7 @@ import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-parcelize")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
@@ -34,7 +35,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.reader_app"
applicationId = "dev.fevirtus.reader"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
@@ -66,6 +67,10 @@ android {
}
}
dependencies {
implementation("androidx.media:media:1.7.0")
}
flutter {
source = "../.."
}
+20 -4
View File
@@ -7,20 +7,36 @@
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0",
"mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
"android_client_info": {
"package_name": "com.example.reader_app"
"package_name": "dev.fevirtus.reader"
}
},
"oauth_client": [
{
"client_id": "308259929553-6k3q1g76skt3id4e2mk9k6pr5l7gdtju.apps.googleusercontent.com",
"client_id": "308259929553-58cnurk30t6stf9ebj7p5jv1b5gftr29.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.example.reader_app",
"package_name": "dev.fevirtus.reader",
"certificate_hash": "72eb1a744349efea8402128e2d9c98c989ec62b5"
}
},
{
"client_id": "308259929553-fd8teopc4chi2jjd8kr5vn9inn35ar6j.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "fa21a3e6a319b71b2dd0ef9573b22046dba5d55c"
}
},
{
"client_id": "308259929553-kdfvnu11cq6k9a2l1b3gtrmfmtsggduk.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "dev.fevirtus.reader",
"certificate_hash": "f7e9f7ec9bafd1de69934b2c9b52ee491d73bad7"
}
},
{
"client_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3
+8 -1
View File
@@ -2,8 +2,11 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="reader_app"
android:label="Virtus's Reader"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
@@ -34,6 +37,10 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name=".tts.ReaderTtsMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -6,12 +6,19 @@ import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background"
private val mediaChannelName = "reader_app/tts_media"
private val mediaEventsChannelName = "reader_app/tts_media_events"
private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -35,6 +42,117 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, mediaChannelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"initialize" -> {
val enabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.initialize(this, enabled)
result.success(ReaderTtsMediaBridge.snapshot())
}
"getSnapshot" -> result.success(ReaderTtsMediaBridge.snapshot())
"startReading" -> {
val startIndex = call.argument<Int>("startIndex") ?: 0
val contentKey = call.argument<String>("contentKey")
val title = call.argument<String>("title")
val speed = call.argument<Double>("speed") ?: 0.9
val language = call.argument<String>("language") ?: "vi-VN"
val voiceName = call.argument<String>("voiceName")
val backgroundModeEnabled = call.argument<Boolean>("backgroundModeEnabled") ?: true
ReaderTtsMediaService.startReading(
this,
parseSegments(call.argument<List<*>>("segments")),
startIndex,
contentKey,
title,
speed,
language,
voiceName,
backgroundModeEnabled,
)
result.success(null)
}
"pause" -> {
ReaderTtsMediaService.pause(this)
result.success(null)
}
"resume" -> {
ReaderTtsMediaService.resume(this)
result.success(null)
}
"stop" -> {
ReaderTtsMediaService.stop(this)
result.success(null)
}
"skipForward" -> {
ReaderTtsMediaService.skipForward(this)
result.success(null)
}
"skipBack" -> {
ReaderTtsMediaService.skipBack(this)
result.success(null)
}
"setSpeed" -> {
val speed = call.argument<Double>("speed") ?: 0.9
ReaderTtsMediaService.setSpeed(this, speed)
result.success(null)
}
"setVoiceByName" -> {
ReaderTtsMediaService.setVoice(
this,
call.argument<String>("voiceName"),
call.argument<String>("language"),
)
result.success(null)
}
"setBackgroundModeEnabled" -> {
val enabled = call.argument<Boolean>("enabled") ?: true
ReaderTtsMediaService.setBackgroundModeEnabled(this, enabled)
result.success(null)
}
"areNotificationsEnabled" -> {
result.success(NotificationManagerCompat.from(this).areNotificationsEnabled())
}
"openNotificationSettings" -> {
openNotificationSettings()
result.success(null)
}
"dispose" -> result.success(null)
else -> result.notImplemented()
}
}
EventChannel(flutterEngine.dartExecutor.binaryMessenger, mediaEventsChannelName)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
ReaderTtsMediaBridge.attachSink(events)
}
override fun onCancel(arguments: Any?) {
ReaderTtsMediaBridge.detachSink()
}
},
)
}
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 {
@@ -76,6 +194,19 @@ class MainActivity : FlutterActivity() {
wakeLock = null
}
private fun openNotificationSettings() {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
}
startActivity(intent)
}
override fun onDestroy() {
setWakeLockEnabled(false)
super.onDestroy()
@@ -0,0 +1,44 @@
package com.example.reader_app.tts
import io.flutter.plugin.common.EventChannel
object ReaderTtsMediaBridge {
private var eventSink: EventChannel.EventSink? = null
private var latestSnapshot: Map<String, Any?> = defaultSnapshot()
@Synchronized
fun attachSink(sink: EventChannel.EventSink) {
eventSink = sink
sink.success(HashMap(latestSnapshot))
}
@Synchronized
fun detachSink() {
eventSink = null
}
@Synchronized
fun publish(snapshot: Map<String, Any?>) {
latestSnapshot = HashMap(snapshot)
eventSink?.success(HashMap(latestSnapshot))
}
@Synchronized
fun snapshot(): Map<String, Any?> = HashMap(latestSnapshot)
private fun defaultSnapshot(): Map<String, Any?> = hashMapOf(
"status" to "idle",
"paragraphIndex" to 0,
"totalParagraphs" to 0,
"activeParagraphIndex" to -1,
"progressStart" to -1,
"progressEnd" to -1,
"contentKey" to null,
"completedCount" to 0,
"backgroundModeEnabled" to true,
"language" to "vi-VN",
"voiceName" to null,
"availableVietnameseVoices" to emptyList<Map<String, String>>()
)
}
@@ -0,0 +1,924 @@
package com.example.reader_app.tts
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Parcelable
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media.app.NotificationCompat.MediaStyle
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R
import kotlinx.parcelize.Parcelize
import java.util.Locale
@Parcelize
data class ReaderTtsSegment(
val text: String,
val paragraphIndex: Int,
val start: Int,
val end: Int,
) : Parcelable
class ReaderTtsMediaService : Service(), TextToSpeech.OnInitListener {
companion object {
private const val NOTIFICATION_ID = 46021
private const val CHANNEL_ID = "reader_tts_playback"
private const val CHANNEL_NAME = "Reader TTS"
private const val BASE_SPEED = 0.9
private const val TAG = "ReaderTtsMediaService"
private const val HEALTH_CHECK_INTERVAL_MS = 1500L
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_PAUSE = "com.example.reader_app.tts.PAUSE"
const val ACTION_RESUME = "com.example.reader_app.tts.RESUME"
const val ACTION_STOP = "com.example.reader_app.tts.STOP"
const val ACTION_SKIP_FORWARD = "com.example.reader_app.tts.SKIP_FORWARD"
const val ACTION_SKIP_BACK = "com.example.reader_app.tts.SKIP_BACK"
const val ACTION_SET_SPEED = "com.example.reader_app.tts.SET_SPEED"
const val ACTION_SET_VOICE = "com.example.reader_app.tts.SET_VOICE"
const val ACTION_SET_BACKGROUND_MODE = "com.example.reader_app.tts.SET_BACKGROUND_MODE"
const val EXTRA_SEGMENTS = "segments"
const val EXTRA_START_INDEX = "startIndex"
const val EXTRA_CONTENT_KEY = "contentKey"
const val EXTRA_TITLE = "title"
const val EXTRA_SPEED = "speed"
const val EXTRA_LANGUAGE = "language"
const val EXTRA_VOICE_NAME = "voiceName"
const val EXTRA_BACKGROUND_MODE_ENABLED = "backgroundModeEnabled"
const val EXTRA_CLEAR_CONTENT_KEY = "clearContentKey"
fun initialize(context: Context, backgroundModeEnabled: Boolean) {
context.startService(
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_INIT
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
}
fun startReading(
context: Context,
segments: ArrayList<ReaderTtsSegment>,
startIndex: Int,
contentKey: String?,
title: String?,
speed: Double,
language: String,
voiceName: String?,
backgroundModeEnabled: Boolean,
) {
ContextCompat.startForegroundService(
context,
Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_START_READING
putParcelableArrayListExtra(EXTRA_SEGMENTS, segments)
putExtra(EXTRA_START_INDEX, startIndex)
putExtra(EXTRA_CONTENT_KEY, contentKey)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_SPEED, speed)
putExtra(EXTRA_LANGUAGE, language)
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, backgroundModeEnabled)
},
)
}
fun pause(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_PAUSE
})
fun resume(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_RESUME
})
fun stop(context: Context, clearContentKey: Boolean = true) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_STOP
putExtra(EXTRA_CLEAR_CONTENT_KEY, clearContentKey)
})
fun skipForward(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SKIP_FORWARD
})
fun skipBack(context: Context) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SKIP_BACK
})
fun setSpeed(context: Context, speed: Double) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_SPEED
putExtra(EXTRA_SPEED, speed)
})
fun setVoice(context: Context, voiceName: String?, language: String?) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_VOICE
putExtra(EXTRA_VOICE_NAME, voiceName)
putExtra(EXTRA_LANGUAGE, language)
})
fun setBackgroundModeEnabled(context: Context, enabled: Boolean) =
context.startService(Intent(context, ReaderTtsMediaService::class.java).apply {
action = ACTION_SET_BACKGROUND_MODE
putExtra(EXTRA_BACKGROUND_MODE_ENABLED, enabled)
})
}
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var mediaSession: MediaSessionCompat
private lateinit var audioManager: AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
private var tts: TextToSpeech? = null
private var isTtsReady = false
private var isForegroundActive = false
private var status = "idle"
private var speed = 0.9
private var language = "vi-VN"
private var voiceName: String? = null
private var contentKey: String? = null
private var title: String? = null
private var segments: List<ReaderTtsSegment> = emptyList()
private var currentIndex = 0
private var completedCount = 0
private var backgroundModeEnabled = true
private var availableVoices: List<Map<String, String>> = emptyList()
private var sessionGeneration = 0
private var lastStartedUtterance: String? = null
private var currentUtteranceId: String? = null
private var currentUtteranceStarted = false
private var pendingReplayAfterInit = false
private var currentSegmentRetry = 0
private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog: Runnable? = null
private var pausedByAudioFocus = false
private var lastSpeakRequestTimeMs = 0L
private val playbackHealthRunnable = object : Runnable {
override fun run() {
runPlaybackHealthCheck()
mainHandler.postDelayed(this, HEALTH_CHECK_INTERVAL_MS)
}
}
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
mainHandler.post {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
if (status == "playing") {
pausedByAudioFocus = true
handlePause()
}
}
AudioManager.AUDIOFOCUS_GAIN -> {
if (pausedByAudioFocus && status == "paused") {
pausedByAudioFocus = false
handleResume()
}
}
}
}
}
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
createNotificationChannel()
setupMediaSession()
setupTextToSpeech()
mainHandler.postDelayed(playbackHealthRunnable, HEALTH_CHECK_INTERVAL_MS)
publishSnapshot()
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_INIT -> {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
publishSnapshot()
}
ACTION_START_READING -> handleStartReading(intent)
ACTION_PAUSE -> handlePause()
ACTION_RESUME -> handleResume()
ACTION_STOP -> handleStop(
clearContentKey = intent.getBooleanExtra(EXTRA_CLEAR_CONTENT_KEY, true),
)
ACTION_SKIP_FORWARD -> handleSkip(1)
ACTION_SKIP_BACK -> handleSkip(-1)
ACTION_SET_SPEED -> {
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
applyVoiceAndSpeedSettings()
publishSnapshot()
}
ACTION_SET_VOICE -> {
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
applyVoiceAndSpeedSettings()
publishSnapshot()
}
ACTION_SET_BACKGROUND_MODE -> {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
syncNotificationState()
publishSnapshot()
}
}
return START_STICKY
}
private fun setupTextToSpeech() {
tts = TextToSpeech(applicationContext, this)
tts?.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
lastStartedUtterance = utteranceId
currentUtteranceStarted = true
currentSegmentRetry = 0
status = "playing"
scheduleUtteranceWatchdog(utteranceId)
syncNotificationState()
publishSnapshot()
}
}
override fun onDone(utteranceId: String?) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState()
handleUtteranceCompleted(parseUtteranceIndex(utteranceId))
}
}
@Deprecated("Deprecated in Java")
override fun onError(utteranceId: String?) {
onError(utteranceId, TextToSpeech.ERROR)
}
override fun onError(utteranceId: String?, errorCode: Int) {
if (utteranceId == null) return
mainHandler.post {
if (!isActiveUtterance(utteranceId)) return@post
if (utteranceId != currentUtteranceId) return@post
clearUtteranceRuntimeState()
handlePlaybackFailure()
}
}
},
)
}
override fun onInit(initStatus: Int) {
isTtsReady = initStatus == TextToSpeech.SUCCESS
if (isTtsReady) {
refreshAvailableVoices()
applyVoiceAndSpeedSettings()
if ((pendingReplayAfterInit || status == "playing") && segments.isNotEmpty()) {
pendingReplayAfterInit = false
speakCurrentSegment(forceRestart = true)
}
} else {
status = "idle"
}
syncNotificationState()
publishSnapshot()
}
private fun refreshAvailableVoices() {
val ttsInstance = tts ?: return
val vietnameseVoices = ttsInstance.voices
?.filter { voice -> voice.locale?.toLanguageTag()?.lowercase()?.startsWith("vi") == true }
?.mapNotNull { voice ->
val locale = voice.locale?.toLanguageTag() ?: return@mapNotNull null
mapOf("name" to voice.name, "locale" to locale)
}
.orEmpty()
.distinctBy { voice -> "${voice["name"]}:${voice["locale"]}" }
.sortedBy { voice -> voice["name"] }
availableVoices = vietnameseVoices
if (voiceName.isNullOrBlank()) {
val preferred = vietnameseVoices.firstOrNull { voice ->
val normalized = voice["name"]?.lowercase().orEmpty()
normalized.contains("female") || normalized.contains("natural")
} ?: vietnameseVoices.firstOrNull()
voiceName = preferred?.get("name")
language = preferred?.get("locale") ?: language
}
}
private fun applyVoiceAndSpeedSettings() {
val ttsInstance = tts ?: return
ttsInstance.setSpeechRate(speed.toFloat())
val locale = language.toLocale()
ttsInstance.setLanguage(locale)
val matchingVoice = ttsInstance.voices?.firstOrNull { voice ->
voice.name == voiceName && voice.locale?.toLanguageTag() == language
}
if (matchingVoice != null) {
ttsInstance.voice = matchingVoice
}
}
private fun handleStartReading(intent: Intent) {
backgroundModeEnabled = intent.getBooleanExtra(
EXTRA_BACKGROUND_MODE_ENABLED,
backgroundModeEnabled,
)
speed = intent.getDoubleExtra(EXTRA_SPEED, speed)
language = intent.getStringExtra(EXTRA_LANGUAGE) ?: language
voiceName = intent.getStringExtra(EXTRA_VOICE_NAME)
contentKey = intent.getStringExtra(EXTRA_CONTENT_KEY)
title = intent.getStringExtra(EXTRA_TITLE)
segments = extractSegments(intent)
currentIndex = intent.getIntExtra(EXTRA_START_INDEX, 0)
.coerceIn(0, (segments.size - 1).coerceAtLeast(0))
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "playing"
pausedByAudioFocus = false
pendingReplayAfterInit = false
tts?.stop()
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handlePause() {
if (status != "playing") return
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "paused"
pendingReplayAfterInit = false
tts?.stop()
syncNotificationState()
publishSnapshot()
}
private fun handleResume() {
if (segments.isEmpty()) return
status = "playing"
sessionGeneration += 1
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handleStop(clearContentKey: Boolean) {
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "idle"
currentIndex = 0
segments = emptyList()
title = null
if (clearContentKey) {
contentKey = null
}
tts?.stop()
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
}
private fun handleSkip(direction: Int) {
if (segments.isEmpty()) return
val nextIndex = (currentIndex + direction).coerceIn(0, segments.lastIndex)
if (nextIndex == currentIndex && status == "idle") return
currentIndex = nextIndex
sessionGeneration += 1
clearUtteranceRuntimeState()
status = "playing"
pendingReplayAfterInit = false
tts?.stop()
publishSnapshot()
if (!isTtsReady) return
speakCurrentSegment(forceRestart = true)
}
private fun handleUtteranceCompleted(completedIndex: Int) {
if (status != "playing") return
if (completedIndex != currentIndex) return
val nextIndex = currentIndex + 1
if (nextIndex >= segments.size) {
status = "idle"
currentIndex = 0
completedCount += 1
clearUtteranceRuntimeState()
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
return
}
currentIndex = nextIndex
speakCurrentSegment(forceRestart = false)
}
private fun handlePlaybackFailure() {
status = "idle"
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
abandonAudioFocus()
syncNotificationState()
publishSnapshot()
stopSelf()
}
private fun speakCurrentSegment(forceRestart: Boolean) {
if (segments.isEmpty() || !isTtsReady) return
if (!requestAudioFocus()) {
handlePlaybackFailure()
return
}
val segment = segments.getOrNull(currentIndex) ?: run {
handlePlaybackFailure()
return
}
applyVoiceAndSpeedSettings()
status = "playing"
// Reset retry counter when advancing to a new segment; keep it when retrying same segment.
if (!forceRestart) {
currentSegmentRetry = 0
}
syncNotificationState()
publishSnapshot()
val utteranceId = "${sessionGeneration}:${currentIndex}:${System.nanoTime()}"
lastStartedUtterance = if (forceRestart) null else lastStartedUtterance
currentUtteranceId = utteranceId
currentUtteranceStarted = false
lastSpeakRequestTimeMs = System.currentTimeMillis()
scheduleUtteranceWatchdog(utteranceId)
val speakResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, Bundle(), utteranceId)
} else {
@Suppress("DEPRECATION")
tts?.speak(segment.text, TextToSpeech.QUEUE_FLUSH, null)
}
if (speakResult == TextToSpeech.ERROR) {
recoverFromSilentPlayback("speak_error")
}
}
private fun scheduleUtteranceWatchdog(utteranceId: String) {
clearUtteranceWatchdog()
val segment = currentSegment() ?: return
val timeoutMs = estimateUtteranceTimeoutMs(segment.text)
val guard = Runnable {
if (status != "playing") return@Runnable
if (utteranceId != currentUtteranceId) return@Runnable
recoverFromSilentPlayback("watchdog_timeout")
}
utteranceWatchdog = guard
mainHandler.postDelayed(guard, timeoutMs)
}
private fun clearUtteranceWatchdog() {
utteranceWatchdog?.let(mainHandler::removeCallbacks)
utteranceWatchdog = null
}
private fun clearUtteranceRuntimeState() {
clearUtteranceWatchdog()
lastStartedUtterance = null
currentUtteranceId = null
currentUtteranceStarted = false
consecutiveSilentHealthChecks = 0
}
private fun estimateUtteranceTimeoutMs(text: String): Long {
val safeSpeed = speed.coerceIn(0.2, 1.5)
val multiplier = (BASE_SPEED / safeSpeed).coerceIn(0.5, 3.0)
// Use 200ms/char (was 90ms) and a larger 10s buffer so the watchdog does not
// fire prematurely for longer Vietnamese sentences (e.g. ~150 chars ≈ 17 s at 0.9×).
val estimate = (text.length * 200L * multiplier).toLong() + 10_000L
return estimate.coerceIn(15_000L, 180_000L)
}
private fun recoverFromSilentPlayback(reason: String) {
if (status != "playing") return
Log.w(TAG, "Recover from silent playback: $reason (index=$currentIndex retry=$currentSegmentRetry)")
if (segments.isEmpty()) {
handlePlaybackFailure()
return
}
clearUtteranceRuntimeState()
if (currentSegmentRetry >= 2) {
handlePlaybackFailure()
return
}
currentSegmentRetry += 1
if (currentSegmentRetry >= 2) {
rebuildTtsEngineForRecovery(reason)
return
}
tts?.stop()
speakCurrentSegment(forceRestart = true)
}
private fun rebuildTtsEngineForRecovery(reason: String) {
Log.w(TAG, "Rebuilding TextToSpeech engine for recovery: $reason")
pendingReplayAfterInit = true
isTtsReady = false
tts?.stop()
tts?.shutdown()
setupTextToSpeech()
}
private fun runPlaybackHealthCheck() {
if (status != "playing") return
if (segments.isEmpty()) return
val ttsInstance = tts
if (ttsInstance == null) {
rebuildTtsEngineForRecovery("tts_instance_null")
return
}
if (!isTtsReady) {
if (!pendingReplayAfterInit) {
rebuildTtsEngineForRecovery("tts_not_ready")
}
return
}
val isSpeaking = try {
ttsInstance.isSpeaking
} catch (_: Exception) {
false
}
if (!currentUtteranceStarted) {
if (!isSpeaking) {
// Allow a grace period after speak() is called before flagging as silent.
// onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation.
val elapsedSinceSpeak = System.currentTimeMillis() - lastSpeakRequestTimeMs
if (elapsedSinceSpeak > 4_000L) {
recoverFromSilentPlayback("no_onStart_and_not_speaking")
}
}
return
}
if (isSpeaking) {
consecutiveSilentHealthChecks = 0
return
}
consecutiveSilentHealthChecks += 1
if (consecutiveSilentHealthChecks < 2) {
return
}
// Engine stopped speaking but onDone was never delivered; advance manually.
consecutiveSilentHealthChecks = 0
clearUtteranceRuntimeState()
handleUtteranceCompleted(currentIndex)
}
private fun requestAudioFocus(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = audioFocusRequest
?: AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener(audioFocusListener)
.build()
.also { audioFocusRequest = it }
audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(
audioFocusListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN,
) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
}
private fun abandonAudioFocus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let(audioManager::abandonAudioFocusRequest)
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(audioFocusListener)
}
}
private fun isActiveUtterance(utteranceId: String): Boolean {
val generation = utteranceId.substringBefore(':').toIntOrNull() ?: return false
return generation == sessionGeneration
}
private fun parseUtteranceIndex(utteranceId: String): Int {
val parts = utteranceId.split(':')
return parts.getOrNull(1)?.toIntOrNull() ?: currentIndex
}
private fun currentSegment(): ReaderTtsSegment? = segments.getOrNull(currentIndex)
private fun currentProgressLabel(): String {
if (segments.isEmpty()) return voiceName ?: language
return "Câu ${currentIndex + 1}/${segments.size}"
}
private fun appLabel(): String = applicationInfo.loadLabel(packageManager).toString()
private fun buildLaunchIntent(): PendingIntent? {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return launchIntent?.let {
PendingIntent.getActivity(
this,
100,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
private fun buildServicePendingIntent(action: String): PendingIntent {
return PendingIntent.getService(
this,
action.hashCode(),
Intent(this, ReaderTtsMediaService::class.java).apply {
this.action = action
if (action == ACTION_STOP) {
putExtra(EXTRA_CLEAR_CONTENT_KEY, true)
}
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@SuppressLint("MissingPermission")
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title ?: appLabel())
.setContentText(currentProgressLabel())
.setContentIntent(buildLaunchIntent())
.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
.setOnlyAlertOnce(true)
.setOngoing(status == "playing")
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.addAction(
android.R.drawable.ic_media_previous,
"Lùi câu",
buildServicePendingIntent(ACTION_SKIP_BACK),
)
.addAction(
if (status == "playing") android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (status == "playing") "Tạm dừng" else "Tiếp tục",
buildServicePendingIntent(if (status == "playing") ACTION_PAUSE else ACTION_RESUME),
)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Dừng",
buildServicePendingIntent(ACTION_STOP),
)
.addAction(
android.R.drawable.ic_media_next,
"Tới câu",
buildServicePendingIntent(ACTION_SKIP_FORWARD),
)
.setStyle(
MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 3),
)
.build()
private fun setupMediaSession() {
mediaSession = MediaSessionCompat(this, "ReaderTtsMediaSession")
mediaSession.setCallback(
object : MediaSessionCompat.Callback() {
override fun onPlay() = handleResume()
override fun onPause() = handlePause()
override fun onStop() = handleStop(clearContentKey = true)
override fun onSkipToNext() = handleSkip(1)
override fun onSkipToPrevious() = handleSkip(-1)
},
)
mediaSession.isActive = true
updateMediaSessionState()
}
private fun updateMediaSessionState() {
val playbackState = when (status) {
"playing" -> PlaybackStateCompat.STATE_PLAYING
"paused" -> PlaybackStateCompat.STATE_PAUSED
else -> PlaybackStateCompat.STATE_STOPPED
}
val actions = PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
PlaybackStateCompat.ACTION_STOP or
PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
mediaSession.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(actions)
.setState(playbackState, currentIndex.toLong(), 1.0f)
.build(),
)
mediaSession.setMetadata(
MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title ?: appLabel())
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentProgressLabel())
.build(),
)
}
@SuppressLint("MissingPermission")
private fun syncNotificationState() {
updateMediaSessionState()
if (!backgroundModeEnabled) {
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
notificationManager.cancel(NOTIFICATION_ID)
return
}
when (status) {
"playing" -> {
val notification = buildNotification()
if (!isForegroundActive) {
startForeground(NOTIFICATION_ID, notification)
isForegroundActive = true
} else {
notificationManager.notify(NOTIFICATION_ID, notification)
}
}
"paused" -> {
val notification = buildNotification()
if (isForegroundActive) {
stopForeground(false)
isForegroundActive = false
}
notificationManager.notify(NOTIFICATION_ID, notification)
}
else -> {
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
notificationManager.cancel(NOTIFICATION_ID)
}
}
}
private fun publishSnapshot() {
val segment = currentSegment()
val canExposeSegmentProgress = status == "playing" && currentUtteranceStarted
ReaderTtsMediaBridge.publish(
hashMapOf(
"status" to status,
"paragraphIndex" to currentIndex,
"totalParagraphs" to segments.size,
"activeParagraphIndex" to if (canExposeSegmentProgress) {
(segment?.paragraphIndex ?: -1)
} else {
-1
},
"progressStart" to if (canExposeSegmentProgress) {
(segment?.start ?: -1)
} else {
-1
},
"progressEnd" to if (canExposeSegmentProgress) {
(segment?.end ?: -1)
} else {
-1
},
"contentKey" to contentKey,
"completedCount" to completedCount,
"backgroundModeEnabled" to backgroundModeEnabled,
"language" to language,
"voiceName" to voiceName,
"availableVietnameseVoices" to availableVoices,
),
)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Điều khiển đọc truyện bằng TTS"
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun extractSegments(intent: Intent): List<ReaderTtsSegment> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(EXTRA_SEGMENTS, ReaderTtsSegment::class.java)
?: arrayListOf()
} else {
@Suppress("DEPRECATION")
(intent.getParcelableArrayListExtra<ReaderTtsSegment>(EXTRA_SEGMENTS)
?: arrayListOf())
}
}
override fun onDestroy() {
mainHandler.removeCallbacks(playbackHealthRunnable)
status = "idle"
currentIndex = 0
segments = emptyList()
clearUtteranceRuntimeState()
pendingReplayAfterInit = false
publishSnapshot()
tts?.stop()
tts?.shutdown()
abandonAudioFocus()
if (isForegroundActive) {
stopForeground(true)
isForegroundActive = false
}
mediaSession.release()
super.onDestroy()
}
}
private fun String.toLocale(): Locale {
val normalized = replace('_', '-')
return Locale.forLanguageTag(normalized).takeIf { it.language.isNotBlank() }
?: Locale("vi", "VN")
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
+2 -2
View File
@@ -431,7 +431,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -488,7 +488,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Reader App</string>
<string>Virtus's Reader</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+11 -1
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -36,7 +37,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: RouteNames.search,
builder: (context, state) => const SearchScreen(),
builder: (context, state) {
final query = state.uri.queryParameters;
return SearchScreen(
key: ValueKey(state.uri.toString()),
initialQuery: query['q'],
initialGenre: query['genre'],
initialStatus: query['status'],
initialSort: query['sort'] ?? 'latest',
);
},
),
GoRoute(
path: RouteNames.genres,
@@ -23,11 +23,40 @@ class BrowseParams {
this.page = 1,
});
String? _normalizedStatus() {
final raw = status?.trim();
if (raw == null || raw.isEmpty) return null;
switch (raw.toLowerCase()) {
case 'ongoing':
return 'Đang ra';
case 'completed':
return 'Hoàn thành';
case 'hiatus':
return 'Tạm ngưng';
default:
return raw;
}
}
String _normalizedSort() {
final raw = sort.trim();
if (raw.isEmpty) return 'latest';
switch (raw.toLowerCase()) {
case 'latest':
case 'popular':
case 'rating':
case 'name':
return raw.toLowerCase();
default:
return 'latest';
}
}
Map<String, dynamic> toQueryParams() => {
if (query != null && query!.isNotEmpty) 'q': query,
if (genre != null) 'genre': genre,
if (status != null) 'status': status,
'sort': sort,
if (_normalizedStatus() != null) 'status': _normalizedStatus(),
'sort': _normalizedSort(),
'page': page.toString(),
'limit': '20',
};
@@ -56,18 +85,39 @@ class BrowseResult {
final int totalCount;
final int totalPages;
final int currentPage;
final bool isLoadingMore;
const BrowseResult({
required this.items,
required this.totalCount,
required this.totalPages,
required this.currentPage,
this.isLoadingMore = false,
});
bool get hasMore => currentPage < totalPages;
BrowseResult copyWith({
List<NovelModel>? items,
int? totalCount,
int? totalPages,
int? currentPage,
bool? isLoadingMore,
}) {
return BrowseResult(
items: items ?? this.items,
totalCount: totalCount ?? this.totalCount,
totalPages: totalPages ?? this.totalPages,
currentPage: currentPage ?? this.currentPage,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
);
}
}
class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
final Ref _ref;
BrowseParams _params = const BrowseParams();
bool _isLoadingMore = false;
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch();
@@ -75,25 +125,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
BrowseParams get params => _params;
Future<BrowseResult> _fetchPage(BrowseParams params) async {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get('/api/novels/browse', queryParameters: params.toQueryParams());
final data = res.data as Map<String, dynamic>;
return BrowseResult(
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: (data['totalCount'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 1,
currentPage: (data['currentPage'] as num?)?.toInt() ?? params.page,
);
}
Future<void> fetch({BrowseParams? params}) async {
if (params != null) _params = params;
state = const AsyncValue.loading();
try {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams());
final data = res.data as Map<String, dynamic>;
state = AsyncValue.data(BrowseResult(
items: (data['items'] as List).map((e) => NovelModel.fromJson(e as Map<String, dynamic>)).toList(),
totalCount: data['totalCount'] as int,
totalPages: data['totalPages'] as int,
currentPage: data['currentPage'] as int,
));
final firstPageParams = _params.copyWith(page: 1);
final result = await _fetchPage(firstPageParams);
_params = firstPageParams;
state = AsyncValue.data(result);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
Future<void> updateParams(BrowseParams params) => fetch(params: params);
Future<void> loadNextPage() async {
final current = state.valueOrNull;
if (current == null || !current.hasMore || _isLoadingMore) return;
_isLoadingMore = true;
state = AsyncValue.data(current.copyWith(isLoadingMore: true));
try {
final nextParams = _params.copyWith(page: current.currentPage + 1);
final nextPage = await _fetchPage(nextParams);
_params = nextParams;
final merged = [...current.items, ...nextPage.items];
state = AsyncValue.data(nextPage.copyWith(items: merged, isLoadingMore: false));
} catch (e, st) {
state = AsyncValue.error(e, st);
} finally {
_isLoadingMore = false;
}
}
}
final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -74,22 +75,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required bool isActiveParagraph,
required int highlightStart,
required int highlightEnd,
required Function(int charOffset) onSentenceTap,
}) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList();
final safeStart = highlightStart.clamp(0, paragraph.length);
final safeEnd = highlightEnd.clamp(0, paragraph.length);
if (safeEnd <= safeStart) {
if (sentenceMatches.isEmpty) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
onTap: () => onSentenceTap(0),
);
}
@@ -98,16 +93,28 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
fontWeight: FontWeight.w600,
);
return RichText(
textAlign: textAlign,
text: TextSpan(
return SelectableText.rich(
TextSpan(
style: style,
children: [
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)),
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
],
children: sentenceMatches.map((match) {
final sentence = match.group(0)!;
final start = match.start;
final end = match.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
return TextSpan(
text: sentence,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
),
textAlign: textAlign,
);
}
@@ -181,6 +188,48 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
});
}
/// Handle TTS state transitions that require navigation or restarts.
/// Called once from [build] via [ref.listen] — safe to run side effects here.
void _onTtsStateChanged(TtsState? previous, TtsState next) {
// Guard: only act when something meaningful changed.
if (previous == null) return;
final chapterAsync = ref.read(chapterProvider(widget.chapterId));
final chapter = chapterAsync.valueOrNull;
if (chapter == null) return;
// Chapter-completion → auto-advance to next chapter.
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
final nextChapterId = chapter.nextChapterId!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
});
}
return;
}
// Pending auto-start for this chapter (set by previous chapter's completion).
if (next.pendingAutoStartChapterId == chapter.id &&
_autoStartQueuedChapterId != chapter.id) {
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
}
@override
void dispose() {
_uiAutoHideTimer?.cancel();
@@ -686,7 +735,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Wrap(
spacing: 8,
runSpacing: 8,
children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
children: [0.45, 0.675, 0.9, 1.125, 1.35, 1.8].map((speed) {
final selected = tts.speed == speed;
return ChoiceChip(
label: Text(formatTtsSpeedLabel(speed)),
@@ -790,6 +839,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider);
// Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
Color readerBackground;
Color readerTextColor;
Color readerMutedColor;
@@ -835,33 +887,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final shouldHighlightTts = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (tts.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = tts.completedCount;
if (tts.contentKey == chapter.id && chapter.nextChapterId != null) {
final nextChapterId = chapter.nextChapterId!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
});
}
}
if (tts.pendingAutoStartChapterId == chapter.id &&
_autoStartQueuedChapterId != chapter.id) {
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -990,6 +1015,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
onSentenceTap: (charOffset) {
ref.read(ttsProvider.notifier).startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
),
),
],
@@ -30,7 +30,7 @@ class TtsPlayerWidget extends ConsumerWidget {
final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier);
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0];
const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
Future<void> start() async {
if (tts.status == TtsStatus.paused) {
+515 -130
View File
@@ -7,7 +7,7 @@ import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45;
const double kTtsBaseSpeechRate = 0.9;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
@@ -32,6 +32,13 @@ class _TtsSegment {
final int paragraphIndex;
final int start;
final int end;
Map<String, Object?> toMap() => {
'text': text,
'paragraphIndex': paragraphIndex,
'start': start,
'end': end,
};
}
class TtsVoice {
@@ -65,7 +72,7 @@ class TtsState {
this.paragraphIndex = 0,
this.totalParagraphs = 0,
this.activeParagraphIndex = -1,
this.speed = 0.45,
this.speed = 0.9,
this.language = 'vi-VN',
this.voiceName,
this.availableVietnameseVoices = const [],
@@ -116,25 +123,48 @@ class TtsState {
batteryOptimizationIgnored:
batteryOptimizationIgnored ?? this.batteryOptimizationIgnored,
pendingAutoStartChapterId: clearPendingAutoStartChapterId
? null
: (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId),
? null
: (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId),
);
bool get isPlaying => status == TtsStatus.playing;
}
class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
TtsNotifier() : super(const TtsState()) {
_initFuture = _init();
}
static const MethodChannel _backgroundChannel = MethodChannel(
'reader_app/tts_background',
);
static const MethodChannel _mediaChannel = MethodChannel(
'reader_app/tts_media',
);
static const EventChannel _mediaEventsChannel = EventChannel(
'reader_app/tts_media_events',
);
final FlutterTts _tts = FlutterTts();
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
StreamSubscription<dynamic>? _mediaEventsSub;
int _playbackGeneration = 0;
bool _isInterruptingPlayback = false;
int _pendingFallbackIndex = -1;
bool _didStartCurrentFallbackUtterance = false;
bool _hasPromptedNotificationSettings = false;
bool get _useNativeAndroidMediaService => Platform.isAndroid;
Future<void> _init() async {
if (_useNativeAndroidMediaService) {
await _initAndroidBridge();
_initialized = true;
return;
}
await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true);
@@ -150,39 +180,85 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
if (Platform.isAndroid) {
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _configureVietnameseVoiceWithFlutterTts();
await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
state = state.copyWith(
status: TtsStatus.playing,
);
_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);
}
unawaited(_syncBackgroundMode());
});
_tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) {
_next();
}
// Fallback playback progression is driven by _playFallbackFromGeneration.
});
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle);
if (_isInterruptingPlayback) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
unawaited(_syncBackgroundMode());
});
await _syncBackgroundMode();
_initialized = true;
}
Future<void> _configureVietnameseVoice() async {
Future<void> _initAndroidBridge() async {
_mediaEventsSub ??= _mediaEventsChannel.receiveBroadcastStream().listen(
_handleAndroidMediaEvent,
onError: (_) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
},
);
await _mediaChannel.invokeMethod<void>('initialize', {
'backgroundModeEnabled': state.backgroundModeEnabled,
});
final snapshot = await _mediaChannel.invokeMethod<dynamic>('getSnapshot');
_applyAndroidSnapshot(snapshot);
await _ensureAndroidMediaNotificationsEnabled();
}
Future<void> _ensureAndroidMediaNotificationsEnabled() async {
if (!_useNativeAndroidMediaService) return;
if (_hasPromptedNotificationSettings) return;
final enabled = await _mediaChannel.invokeMethod<bool>('areNotificationsEnabled') ?? true;
if (enabled) return;
_hasPromptedNotificationSettings = true;
await _mediaChannel.invokeMethod<void>('openNotificationSettings');
}
Future<void> _configureVietnameseVoiceWithFlutterTts() async {
final dynamic voicesRaw = await _tts.getVoices;
String? selectedName;
@@ -191,22 +267,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
final locale = (voice['locale'] ?? voice['language'] ?? '').toString().toLowerCase();
final locale = (voice['locale'] ?? voice['language'] ?? '')
.toString()
.toLowerCase();
return locale.startsWith('vi');
}).toList();
for (final voice in vietnamese) {
final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue;
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
vietnameseVoices.add(TtsVoice(name: name, locale: locale));
}
if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere(
(voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) ||
(voice['name']?.toString().toLowerCase().contains('natural') ?? false),
(voice['name']
?.toString()
.toLowerCase()
.contains('female') ??
false) ||
(voice['name']
?.toString()
.toLowerCase()
.contains('natural') ??
false),
orElse: () => vietnamese.first,
);
selectedName = preferred['name']?.toString();
@@ -219,6 +307,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
}
state = state.copyWith(
language: selectedLanguage,
voiceName: selectedName,
@@ -226,7 +315,176 @@ class TtsNotifier extends StateNotifier<TtsState> {
);
}
void _handleAndroidMediaEvent(dynamic event) {
_applyAndroidSnapshot(event);
}
void _applyAndroidSnapshot(dynamic snapshot) {
if (snapshot is! Map) return;
final data = Map<String, dynamic>.from(
snapshot.map((key, value) => MapEntry(key.toString(), value)),
);
final statusRaw = data['status']?.toString() ?? 'idle';
final status = switch (statusRaw) {
'playing' => TtsStatus.playing,
'paused' => TtsStatus.paused,
_ => TtsStatus.idle,
};
final voicesRaw = data['availableVietnameseVoices'];
final voices = <TtsVoice>[];
if (voicesRaw is List) {
for (final item in voicesRaw) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(
item.map((key, value) => MapEntry(key.toString(), value)),
);
final name = map['name']?.toString();
final locale = map['locale']?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) {
continue;
}
voices.add(TtsVoice(name: name, locale: locale));
}
}
state = state.copyWith(
status: status,
paragraphIndex: (data['paragraphIndex'] as num?)?.toInt() ?? 0,
totalParagraphs: (data['totalParagraphs'] as num?)?.toInt() ?? 0,
activeParagraphIndex: (data['activeParagraphIndex'] as num?)?.toInt() ?? -1,
progressStart: (data['progressStart'] as num?)?.toInt() ?? -1,
progressEnd: (data['progressEnd'] as num?)?.toInt() ?? -1,
contentKey: data['contentKey']?.toString(),
completedCount: (data['completedCount'] as num?)?.toInt() ?? state.completedCount,
language: data['language']?.toString() ?? state.language,
voiceName: data['voiceName']?.toString(),
availableVietnameseVoices: voices,
backgroundModeEnabled:
data['backgroundModeEnabled'] as bool? ?? state.backgroundModeEnabled,
);
}
String _sanitizeForTts(String raw) {
if (raw.isEmpty) return raw;
// Keep natural sentence flow while removing symbols that are usually read out noisily.
final cleaned = raw
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned;
}
List<_TtsSegment> _buildSegments(
String content, {
String? title,
bool includeTitle = true,
}) {
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
final sanitizedTitle = _sanitizeForTts(titleText);
if (sanitizedTitle.isNotEmpty) {
segments.add(
_TtsSegment(
text: sanitizedTitle,
paragraphIndex: -1,
start: -1,
end: -1,
),
);
}
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
final sanitizedSentence = _sanitizeForTts(sentence);
if (sanitizedSentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) {
start = cursor.clamp(0, paragraph.length);
}
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sanitizedSentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
return segments;
}
int _resolveStartIndex(
int paragraphIndex, {
int? startParagraphIndex,
int? startCharOffset,
}) {
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final matchIndex = _segments.indexWhere(
(segment) =>
segment.paragraphIndex == startParagraphIndex &&
(startCharOffset == null || segment.start >= startCharOffset),
);
if (matchIndex >= 0) {
validIndex = matchIndex;
} else {
final fallbackIndex = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
);
if (fallbackIndex >= 0) {
validIndex = fallbackIndex;
}
}
}
return validIndex;
}
Future<void> setVoiceByName(String voiceName) async {
if (_useNativeAndroidMediaService) {
final selected = state.availableVietnameseVoices.where(
(voice) => voice.name == voiceName,
);
if (selected.isEmpty) return;
final voice = selected.first;
await _mediaChannel.invokeMethod<void>('setVoiceByName', {
'voiceName': voice.name,
'language': voice.locale,
});
state = state.copyWith(language: voice.locale, voiceName: voice.name);
return;
}
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return;
@@ -240,6 +498,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
'enabled': enabled,
});
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
return;
}
await _syncBackgroundMode();
if (enabled) {
await ensureBatteryOptimizationIgnored();
@@ -278,23 +546,24 @@ class TtsNotifier extends StateNotifier<TtsState> {
}
Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return;
if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing;
try {
await _backgroundChannel
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive});
await _backgroundChannel.invokeMethod<void>('setWakeLock', {
'enabled': shouldKeepAlive,
});
} catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable.
}
}
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading(
String content, {
int paragraphIndex = 0,
int? startParagraphIndex,
int? startCharOffset,
String? contentKey,
String? title,
bool includeTitle = true,
@@ -303,133 +572,228 @@ class TtsNotifier extends StateNotifier<TtsState> {
await (_initFuture ?? _init());
}
final segments = <_TtsSegment>[];
_segments = _buildSegments(
content,
title: title,
includeTitle: includeTitle,
);
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1));
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) start = cursor.clamp(0, paragraph.length);
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
_segments = segments;
if (_segments.isEmpty) return;
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final startFromVisible = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
if (_segments.isEmpty) {
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
totalParagraphs: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
);
if (startFromVisible >= 0) {
validIndex = startFromVisible;
if (!_useNativeAndroidMediaService) {
await _syncBackgroundMode();
}
return;
}
final validIndex = _resolveStartIndex(
paragraphIndex,
startParagraphIndex: startParagraphIndex,
startCharOffset: startCharOffset,
);
final selectedSegment = _segments[validIndex];
if (_useNativeAndroidMediaService) {
await _ensureAndroidMediaNotificationsEnabled();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
await _mediaChannel.invokeMethod<void>('startReading', {
'contentKey': contentKey,
'title': title,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
});
return;
}
final sessionId = await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
);
await _syncBackgroundMode();
await _speak(validIndex);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<void> _speak(int index) async {
if (index >= _segments.length) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
Future<int> _interruptFallbackPlayback() async {
_playbackGeneration++;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
_isInterruptingPlayback = true;
try {
await _tts.stop();
if (Platform.isAndroid) {
await Future<void>.delayed(const Duration(milliseconds: 120));
}
} finally {
_isInterruptingPlayback = false;
}
final segment = _segments[index];
state = state.copyWith(
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
await _tts.setSpeechRate(state.speed);
await _tts.speak(segment.text);
return _playbackGeneration;
}
Future<void> _next() async {
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) {
Future<void> _playFallbackFromGeneration(int startIndex, int generation) async {
if (startIndex < 0 || startIndex >= _segments.length) {
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
completedCount: state.completedCount + 1,
);
await _syncBackgroundMode();
return;
}
for (var index = startIndex; index < _segments.length; index++) {
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
_pendingFallbackIndex = index;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: index,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
await _tts.setSpeechRate(state.speed);
final result = await _tts.speak(_segments[index].text);
if (generation != _playbackGeneration) return;
if (state.status != TtsStatus.playing) return;
if (result is int && result != 1) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
if (!_didStartCurrentFallbackUtterance) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
}
if (generation != _playbackGeneration) return;
_pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith(
paragraphIndex: next,
activeParagraphIndex: _segments[next].paragraphIndex,
progressStart: _segments[next].start,
progressEnd: _segments[next].end,
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
completedCount: state.completedCount + 1,
);
await _speak(next);
await _syncBackgroundMode();
}
Future<void> pause() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('pause');
return;
}
if (state.status != TtsStatus.playing) return;
_playbackGeneration++;
await _tts.pause();
state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode();
}
Future<void> resume() async {
if (state.status != TtsStatus.paused) return;
state = state.copyWith(status: TtsStatus.playing);
Future<void> _restartFallbackFromIndex(int index) async {
if (_segments.isEmpty) return;
final sessionId = await _interruptFallbackPlayback();
final validIndex = index.clamp(0, _segments.length - 1);
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex);
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
Future<void> resume() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('resume');
return;
}
if (state.status != TtsStatus.paused) return;
await _restartFallbackFromIndex(state.paragraphIndex);
}
Future<void> stop() async {
await _tts.stop();
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('stop');
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
clearContentKey: true,
);
return;
}
await _interruptFallbackPlayback();
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
@@ -442,32 +806,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
}
Future<void> skipForward() async {
await _tts.stop();
await _next();
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('skipForward');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final next = state.paragraphIndex + 1;
if (next >= _segments.length) {
await stop();
return;
}
await _restartFallbackFromIndex(next);
}
Future<void> skipBack() async {
await _tts.stop();
if (state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
state = state.copyWith(
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex,
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end,
);
if (state.status == TtsStatus.playing) await _speak(prev);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('skipBack');
return;
}
if (_segments.isEmpty || state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
await _restartFallbackFromIndex(prev);
}
Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
return;
}
await _tts.setSpeechRate(speed);
}
@override
void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false}));
_tts.stop();
_mediaEventsSub?.cancel();
if (_useNativeAndroidMediaService) {
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
} else {
unawaited(_tts.stop());
}
super.dispose();
}
}
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
import '../../genres/providers/genres_provider.dart';
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
const SearchScreen({
super.key,
this.initialQuery,
this.initialGenre,
this.initialStatus,
this.initialSort = 'latest',
});
final String? initialQuery;
final String? initialGenre;
final String? initialStatus;
final String initialSort;
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce;
String? _selectedGenre;
String? _selectedStatus;
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
('Tên A-Z', 'name'),
];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_syncFromInitialParams(applyImmediately: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_applyFilters();
});
}
@override
void didUpdateWidget(covariant SearchScreen oldWidget) {
super.didUpdateWidget(oldWidget);
final hasRouteFilterChange = oldWidget.initialQuery != widget.initialQuery ||
oldWidget.initialGenre != widget.initialGenre ||
oldWidget.initialStatus != widget.initialStatus ||
oldWidget.initialSort != widget.initialSort;
if (hasRouteFilterChange) {
_syncFromInitialParams(applyImmediately: true);
}
}
@override
void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
_controller.dispose();
_debounce?.cancel();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
ref.read(novelsProvider.notifier).loadNextPage();
}
}
void _syncFromInitialParams({required bool applyImmediately}) {
final incomingQuery = widget.initialQuery?.trim();
_controller.text = incomingQuery == null || incomingQuery.isEmpty ? '' : incomingQuery;
_selectedGenre = widget.initialGenre;
_selectedStatus = widget.initialStatus;
_sort = widget.initialSort;
if (applyImmediately) {
if (mounted) {
setState(() {});
}
_applyFilters();
}
}
void _onQueryChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters);
@@ -167,8 +227,25 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
return const Center(child: Text('Không tìm thấy truyện'));
}
return ListView.builder(
itemCount: result.items.length,
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]),
controller: _scrollController,
itemCount: result.items.length + (result.hasMore || result.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= result.items.length) {
if (result.isLoadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
);
}
// Trigger page load when user reaches the end.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(novelsProvider.notifier).loadNextPage();
});
return const SizedBox(height: 32);
}
return _NovelListTile(novel: result.items[index]);
},
);
},
),
@@ -199,12 +276,14 @@ class _FilterChipDropdown extends StatelessWidget {
return PopupMenuButton<String>(
onSelected: onSelected,
itemBuilder: (_) => items,
child: FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) {},
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
onDeleted: selected ? onClear : null,
child: IgnorePointer(
child: FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) {},
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
onDeleted: selected ? onClear : null,
),
),
);
}
+56
View File
@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
@@ -57,6 +65,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@@ -190,6 +214,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -360,6 +392,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl:
dependency: "direct main"
description:
@@ -376,6 +416,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8
url: "https://pub.dev"
source: hosted
version: "4.11.0"
leak_tracker:
dependency: transitive
description:
@@ -560,6 +608,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver:
dependency: transitive
description:
+10
View File
@@ -56,6 +56,16 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.3
# For information on the generic Dart part of this file, see the
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/app_icon.png"
min_sdk_android: 21
web:
generate: false
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec