10 Commits

Author SHA1 Message Date
virtus 297fc45707 feat: Update reading progress management and enhance chapter navigation
Build Android AAB / build-aab (push) Successful in 18m4s
Build Android APK / build-apk (push) Successful in 20m41s
2026-04-16 13:18:25 +07:00
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
48 changed files with 2682 additions and 491 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 - name: Prepare Android release signing
run: | 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" 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))" 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 exit 1
fi 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 "storeFile=release.keystore"
echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}" echo "storePassword=${ANDROID_KEYSTORE_PASSWORD}"
echo "keyAlias=${ANDROID_KEY_ALIAS}" echo "keyAlias=${RESOLVED_KEY_ALIAS}"
echo "keyPassword=${ANDROID_KEY_PASSWORD}" echo "keyPassword=${ANDROID_KEY_PASSWORD}"
} > android/key.properties } > android/key.properties
else else
echo "Release signing secrets are required for tagged release builds." 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 exit 1
fi fi
@@ -153,13 +172,15 @@ jobs:
echo "$CERT_OUTPUT" echo "$CERT_OUTPUT"
if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then if [ -n "${EXPECTED_ANDROID_SHA1}" ]; then
APK_SHA1=$(echo "$CERT_OUTPUT" | sed -n 's/.*certificate SHA-1 digest: //p' | head -n1 | tr '[:lower:]' '[:upper:]') APK_SHA1=$(echo "$CERT_OUTPUT" | sed -n 's/.*certificate SHA-1 digest: //p' | head -n1)
EXPECTED_SHA1_UPPER=$(echo "${EXPECTED_ANDROID_SHA1}" | tr '[:lower:]' '[:upper:]') 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 "APK SHA-1 mismatch"
echo "Expected: $EXPECTED_SHA1_UPPER" echo "Expected (normalized): $EXPECTED_SHA1_NORMALIZED"
echo "Actual : $APK_SHA1" echo "Actual (normalized): $APK_SHA1_NORMALIZED"
echo "Tip: update EXPECTED_ANDROID_SHA1 to this signer if this is your intended release key"
exit 1 exit 1
fi fi
fi fi
+6 -1
View File
@@ -4,6 +4,7 @@ import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
id("kotlin-parcelize")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services") id("com.google.gms.google-services")
@@ -34,7 +35,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // 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. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
@@ -66,6 +67,10 @@ android {
} }
} }
dependencies {
implementation("androidx.media:media:1.7.0")
}
flutter { flutter {
source = "../.." source = "../.."
} }
+20 -4
View File
@@ -7,20 +7,36 @@
"client": [ "client": [
{ {
"client_info": { "client_info": {
"mobilesdk_app_id": "1:308259929553:android:9142ae16d9ddd8a91c34f0", "mobilesdk_app_id": "1:308259929553:android:14f7828b9b9ca9d31c34f0",
"android_client_info": { "android_client_info": {
"package_name": "com.example.reader_app" "package_name": "dev.fevirtus.reader"
} }
}, },
"oauth_client": [ "oauth_client": [
{ {
"client_id": "308259929553-6k3q1g76skt3id4e2mk9k6pr5l7gdtju.apps.googleusercontent.com", "client_id": "308259929553-58cnurk30t6stf9ebj7p5jv1b5gftr29.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
"android_info": { "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" "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_id": "308259929553-9oame596io3s4lcj9cdb5db6v3i6f6rk.apps.googleusercontent.com",
"client_type": 3 "client_type": 3
+8 -1
View File
@@ -2,8 +2,11 @@
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <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 <application
android:label="reader_app" android:label="Virtus's Reader"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
@@ -34,6 +37,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<service
android:name=".tts.ReaderTtsMediaService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -6,12 +6,19 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.example.reader_app.tts.ReaderTtsMediaBridge
import com.example.reader_app.tts.ReaderTtsMediaService
import com.example.reader_app.tts.ReaderTtsSegment
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background" 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 private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -35,6 +42,117 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() 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 { private fun isIgnoringBatteryOptimizations(): Boolean {
@@ -76,6 +194,19 @@ class MainActivity : FlutterActivity() {
wakeLock = null 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() { override fun onDestroy() {
setWakeLockEnabled(false) setWakeLockEnabled(false)
super.onDestroy() 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>>()
)
}
File diff suppressed because it is too large Load Diff
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; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -488,7 +488,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; 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":"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"}}
"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"
}
}
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> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Reader App</string> <string>Virtus's Reader</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -36,7 +37,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
), ),
GoRoute( GoRoute(
path: RouteNames.search, 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( GoRoute(
path: RouteNames.genres, path: RouteNames.genres,
@@ -23,11 +23,40 @@ class BrowseParams {
this.page = 1, 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() => { Map<String, dynamic> toQueryParams() => {
if (query != null && query!.isNotEmpty) 'q': query, if (query != null && query!.isNotEmpty) 'q': query,
if (genre != null) 'genre': genre, if (genre != null) 'genre': genre,
if (status != null) 'status': status, if (_normalizedStatus() != null) 'status': _normalizedStatus(),
'sort': sort, 'sort': _normalizedSort(),
'page': page.toString(), 'page': page.toString(),
'limit': '20', 'limit': '20',
}; };
@@ -56,18 +85,39 @@ class BrowseResult {
final int totalCount; final int totalCount;
final int totalPages; final int totalPages;
final int currentPage; final int currentPage;
final bool isLoadingMore;
const BrowseResult({ const BrowseResult({
required this.items, required this.items,
required this.totalCount, required this.totalCount,
required this.totalPages, required this.totalPages,
required this.currentPage, 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>> { class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
final Ref _ref; final Ref _ref;
BrowseParams _params = const BrowseParams(); BrowseParams _params = const BrowseParams();
bool _isLoadingMore = false;
NovelsNotifier(this._ref) : super(const AsyncValue.loading()) { NovelsNotifier(this._ref) : super(const AsyncValue.loading()) {
fetch(); fetch();
@@ -75,25 +125,53 @@ class NovelsNotifier extends StateNotifier<AsyncValue<BrowseResult>> {
BrowseParams get params => _params; 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 { Future<void> fetch({BrowseParams? params}) async {
if (params != null) _params = params; if (params != null) _params = params;
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
final client = _ref.read(apiClientProvider); final firstPageParams = _params.copyWith(page: 1);
final res = await client.dio.get('/api/novels/browse', queryParameters: _params.toQueryParams()); final result = await _fetchPage(firstPageParams);
final data = res.data as Map<String, dynamic>; _params = firstPageParams;
state = AsyncValue.data(BrowseResult( state = AsyncValue.data(result);
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,
));
} catch (e, st) { } catch (e, st) {
state = AsyncValue.error(e, st); state = AsyncValue.error(e, st);
} }
} }
Future<void> updateParams(BrowseParams params) => fetch(params: params); 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) { final novelsProvider = StateNotifierProvider<NovelsNotifier, AsyncValue<BrowseResult>>((ref) {
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -27,17 +28,21 @@ class ReaderScreen extends ConsumerStatefulWidget {
class _ReaderScreenState extends ConsumerState<ReaderScreen> { class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final ScrollController _scrollCtrl = ScrollController(); final ScrollController _scrollCtrl = ScrollController();
Timer? _uiAutoHideTimer; Timer? _uiAutoHideTimer;
double _readingProgress = 0; final ValueNotifier<double> _readingProgress = ValueNotifier(0);
final ValueNotifier<bool> _showQuickActions = ValueNotifier(true);
String? _activeChapterId; String? _activeChapterId;
bool _isRestoringProgress = false; bool _isRestoringProgress = false;
bool _showQuickActions = true;
double _lastScrollOffset = 0; double _lastScrollOffset = 0;
double _scrollDeltaSinceToggle = 0; double _scrollDeltaSinceToggle = 0;
double _lastReportedOffset = 0;
DateTime? _lastReportedAt;
int _chapterDirection = 0; // -1: previous, 1: next int _chapterDirection = 0; // -1: previous, 1: next
int _lastAutoScrolledParagraph = -1; int _lastAutoScrolledParagraph = -1;
int _lastTtsCompletedCount = 0; int _lastTtsCompletedCount = 0;
String? _autoStartQueuedChapterId; String? _autoStartQueuedChapterId;
final List<GlobalKey> _paragraphKeys = []; final List<GlobalKey> _paragraphKeys = [];
String? _sentenceSlicesChapterId;
List<List<_SentenceSlice>> _sentenceSlicesByParagraph = const [];
List<String> _paragraphsOf(String content) => content List<String> _paragraphsOf(String content) => content
.split(RegExp(r'\n+')) .split(RegExp(r'\n+'))
@@ -68,49 +73,87 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget _buildParagraphText({ Widget _buildParagraphText({
required BuildContext context, required BuildContext context,
required String paragraph, required List<_SentenceSlice> sentenceSlices,
required TextStyle style, required TextStyle style,
required TextStyle highlightStyle,
required TextAlign textAlign, required TextAlign textAlign,
required bool isActiveParagraph, required bool isActiveParagraph,
required int highlightStart, required int highlightStart,
required int highlightEnd, required int highlightEnd,
required Function(int charOffset) onSentenceTap,
}) { }) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) { if (sentenceSlices.isEmpty) {
return SelectableText( return SelectableText(
paragraph, '',
textAlign: textAlign, textAlign: textAlign,
style: style, style: style,
onTap: () => onSentenceTap(0),
); );
} }
final safeStart = highlightStart.clamp(0, paragraph.length); return SelectableText.rich(
final safeEnd = highlightEnd.clamp(0, paragraph.length); TextSpan(
if (safeEnd <= safeStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style, style: style,
); children: sentenceSlices.map((slice) {
} final start = slice.start;
final end = slice.end;
final highlightStyle = style.copyWith( final isCurrentSpoken = isActiveParagraph &&
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80), highlightStart >= 0 &&
fontWeight: FontWeight.w600, highlightEnd > highlightStart &&
); start >= highlightStart &&
end <= highlightEnd;
return RichText( return TextSpan(
textAlign: textAlign, text: slice.text,
text: TextSpan( style: isCurrentSpoken ? highlightStyle : null,
style: style, recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
children: [ );
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)), }).toList(),
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
],
), ),
textAlign: textAlign,
); );
} }
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
ChapterModel chapter,
List<String> paragraphs,
) {
if (_sentenceSlicesChapterId == chapter.id &&
_sentenceSlicesByParagraph.length == paragraphs.length) {
return _sentenceSlicesByParagraph;
}
final sentencePattern = RegExp(r'[^.!?…]+[.!?…]*');
final parsed = <List<_SentenceSlice>>[];
for (final paragraph in paragraphs) {
final matches = sentencePattern.allMatches(paragraph).toList();
if (matches.isEmpty) {
parsed.add([
_SentenceSlice(text: paragraph, start: 0, end: paragraph.length),
]);
continue;
}
parsed.add(
matches
.map(
(match) => _SentenceSlice(
text: match.group(0)!,
start: match.start,
end: match.end,
),
)
.toList(),
);
}
_sentenceSlicesChapterId = chapter.id;
_sentenceSlicesByParagraph = parsed;
return parsed;
}
void _ensureParagraphKeys(int count) { void _ensureParagraphKeys(int count) {
if (_paragraphKeys.length == count) return; if (_paragraphKeys.length == count) return;
_paragraphKeys _paragraphKeys
@@ -176,23 +219,79 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
void initState() { void initState() {
super.initState(); super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollCtrl.addListener(_onScroll); _scrollCtrl.addListener(_onScroll);
}
/// 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 @override
void dispose() { void dispose() {
_uiAutoHideTimer?.cancel(); _uiAutoHideTimer?.cancel();
_scrollCtrl.removeListener(_onScroll); _scrollCtrl.removeListener(_onScroll);
_scrollCtrl.dispose(); _scrollCtrl.dispose();
_readingProgress.dispose();
_showQuickActions.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose(); super.dispose();
} }
bool _shouldReportScroll(double offset) {
final now = DateTime.now();
final elapsedMs = _lastReportedAt == null
? 1000
: now.difference(_lastReportedAt!).inMilliseconds;
final delta = (offset - _lastReportedOffset).abs();
return delta >= 24 || elapsedMs >= 700;
}
void _onScroll() { void _onScroll() {
if (_isRestoringProgress) return; if (_isRestoringProgress) return;
ref.read(readerProvider.notifier).updateScroll(_scrollCtrl.offset); final offset = _scrollCtrl.hasClients ? _scrollCtrl.offset : 0.0;
if (_shouldReportScroll(offset)) {
_lastReportedOffset = offset;
_lastReportedAt = DateTime.now();
ref.read(readerProvider.notifier).updateScroll(offset);
}
final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset; final currentOffset = _scrollCtrl.hasClients ? _scrollCtrl.offset : _lastScrollOffset;
final delta = currentOffset - _lastScrollOffset; final delta = currentOffset - _lastScrollOffset;
@@ -203,12 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_scrollDeltaSinceToggle = delta; _scrollDeltaSinceToggle = delta;
} }
if (_showQuickActions && currentOffset > 120 && _scrollDeltaSinceToggle > 56) { if (_showQuickActions.value && currentOffset > 120 && _scrollDeltaSinceToggle > 56) {
setState(() => _showQuickActions = false); _showQuickActions.value = false;
_scrollDeltaSinceToggle = 0; _scrollDeltaSinceToggle = 0;
} else if (!_showQuickActions && } else if (!_showQuickActions.value &&
(_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) { (_scrollDeltaSinceToggle < -36 || currentOffset <= 40)) {
setState(() => _showQuickActions = true); _showQuickActions.value = true;
_scrollDeltaSinceToggle = 0; _scrollDeltaSinceToggle = 0;
} }
_lastScrollOffset = currentOffset; _lastScrollOffset = currentOffset;
@@ -216,15 +315,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!_scrollCtrl.hasClients) return; if (!_scrollCtrl.hasClients) return;
final max = _scrollCtrl.position.maxScrollExtent; final max = _scrollCtrl.position.maxScrollExtent;
final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0); final next = max <= 0 ? 0.0 : (_scrollCtrl.offset / max).clamp(0.0, 1.0);
if ((next - _readingProgress).abs() > 0.01) { if ((next - _readingProgress.value).abs() > 0.02) {
setState(() => _readingProgress = next); _readingProgress.value = next;
} }
} }
Future<void> _initializeChapterSession(ChapterModel chapter) async { Future<void> _initializeChapterSession(ChapterModel chapter) async {
if (_activeChapterId == chapter.id) return; if (_activeChapterId == chapter.id) return;
final previousChapterId = _activeChapterId;
final switchedChapter = previousChapterId != null && previousChapterId != chapter.id;
_activeChapterId = chapter.id; _activeChapterId = chapter.id;
_readingProgress = 0; _readingProgress.value = 0;
_showQuickActions.value = true;
_lastScrollOffset = 0;
_scrollDeltaSinceToggle = 0;
_lastReportedOffset = 0;
_lastReportedAt = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_isRestoringProgress = true;
_scrollCtrl.jumpTo(0);
_isRestoringProgress = false;
});
ref.read(readerProvider.notifier).open( ref.read(readerProvider.notifier).open(
chapter.novelId, chapter.novelId,
@@ -232,6 +345,13 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.number, chapter.number,
); );
_consumePendingAutoStartForChapter(chapter);
if (switchedChapter) {
ref.read(readerProvider.notifier).resetCurrentChapterProgress();
return;
}
final localStore = ref.read(localStoreProvider); final localStore = ref.read(localStoreProvider);
final saved = await localStore.loadProgress(chapter.novelId); final saved = await localStore.loadProgress(chapter.novelId);
if (!mounted || saved == null) return; if (!mounted || saved == null) return;
@@ -248,6 +368,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_isRestoringProgress = true; _isRestoringProgress = true;
_scrollCtrl.jumpTo(target); _scrollCtrl.jumpTo(target);
_isRestoringProgress = false; _isRestoringProgress = false;
_lastReportedOffset = target;
_lastReportedAt = DateTime.now();
_onScroll(); _onScroll();
}); });
} }
@@ -274,6 +396,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (prevId == null) return; if (prevId == null) return;
setState(() => _chapterDirection = -1); setState(() => _chapterDirection = -1);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_queueAutoStartIfReadingCurrentChapter(chapter.id, prevId);
context.pushReplacement(RouteNames.readerChapter(prevId)); context.pushReplacement(RouteNames.readerChapter(prevId));
} }
@@ -282,9 +405,40 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (nextId == null) return; if (nextId == null) return;
setState(() => _chapterDirection = 1); setState(() => _chapterDirection = 1);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
_queueAutoStartIfReadingCurrentChapter(chapter.id, nextId);
context.pushReplacement(RouteNames.readerChapter(nextId)); context.pushReplacement(RouteNames.readerChapter(nextId));
} }
void _queueAutoStartIfReadingCurrentChapter(
String currentChapterId,
String targetChapterId,
) {
final tts = ref.read(ttsProvider);
final isCurrentlyReading = tts.contentKey == currentChapterId &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (!isCurrentlyReading) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
}
void _consumePendingAutoStartForChapter(ChapterModel chapter) {
final tts = ref.read(ttsProvider);
if (tts.pendingAutoStartChapterId != chapter.id) return;
if (_autoStartQueuedChapterId == chapter.id) return;
_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;
});
}
Future<void> _scrollToTop() async { Future<void> _scrollToTop() async {
if (!_scrollCtrl.hasClients) return; if (!_scrollCtrl.hasClients) return;
await _scrollCtrl.animateTo( await _scrollCtrl.animateTo(
@@ -362,6 +516,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
onTap: () { onTap: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
if (!isCurrent) { if (!isCurrent) {
_queueAutoStartIfReadingCurrentChapter(
currentChapter.id,
item.id,
);
context.pushReplacement(RouteNames.readerChapter(item.id)); context.pushReplacement(RouteNames.readerChapter(item.id));
} }
}, },
@@ -686,7 +844,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 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; final selected = tts.speed == speed;
return ChoiceChip( return ChoiceChip(
label: Text(formatTtsSpeedLabel(speed)), label: Text(formatTtsSpeedLabel(speed)),
@@ -790,6 +948,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId)); final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider); final settings = ref.watch(readingSettingsProvider);
// Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
Color readerBackground; Color readerBackground;
Color readerTextColor; Color readerTextColor;
Color readerMutedColor; Color readerMutedColor;
@@ -829,39 +990,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
data: (chapter) { data: (chapter) {
final paragraphs = _paragraphsOf(chapter.content); final paragraphs = _paragraphsOf(chapter.content);
_ensureParagraphKeys(paragraphs.length); _ensureParagraphKeys(paragraphs.length);
final sentenceSlicesByParagraph =
_sentenceSlicesForChapter(chapter, paragraphs);
final textAlign = _textAlignFor(settings.textAlign); final textAlign = _textAlignFor(settings.textAlign);
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId)); final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final shouldHighlightTts = tts.contentKey == chapter.id && final shouldHighlightTts = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused); (tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
final paragraphStyle = TextStyle(
if (tts.completedCount > _lastTtsCompletedCount) { color: readerTextColor,
_lastTtsCompletedCount = tts.completedCount; fontSize: settings.fontSize,
if (tts.contentKey == chapter.id && chapter.nextChapterId != null) { height: settings.lineHeight,
final nextChapterId = chapter.nextChapterId!; letterSpacing: settings.letterSpacing,
WidgetsBinding.instance.addPostFrameCallback((_) { fontFamily: settings.fontFamily == 'serif'
if (!mounted) return; ? 'Georgia'
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId); : settings.fontFamily == 'mono'
context.pushReplacement(RouteNames.readerChapter(nextChapterId)); ? 'Courier'
}); : null,
}
}
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; final paragraphHighlightStyle = paragraphStyle.copyWith(
}); backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
} fontWeight: FontWeight.w600,
);
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length); _maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -876,9 +1027,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
color: readerBackground, color: readerBackground,
child: Column( child: Column(
children: [ children: [
_TopBar( ValueListenableBuilder<double>(
valueListenable: _readingProgress,
builder: (context, progress, _) {
return _TopBar(
title: _chapterTopBarTitle(chapter), title: _chapterTopBarTitle(chapter),
progress: _readingProgress, progress: progress,
onOpenSettings: () => _openReadingSettingsSheet( onOpenSettings: () => _openReadingSettingsSheet(
chapter.content, chapter.content,
chapter.id, chapter.id,
@@ -886,6 +1040,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
barBackgroundColor: readerBackground, barBackgroundColor: readerBackground,
foregroundColor: readerTextColor, foregroundColor: readerTextColor,
);
},
), ),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
@@ -909,14 +1065,17 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
key: ValueKey(chapter.id), key: ValueKey(chapter.id),
child: Scrollbar( child: Scrollbar(
controller: _scrollCtrl, controller: _scrollCtrl,
child: SingleChildScrollView( child: CustomScrollView(
controller: _scrollCtrl, controller: _scrollCtrl,
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
settings.horizontalPadding, settings.horizontalPadding,
16, 16,
settings.horizontalPadding, settings.horizontalPadding,
24, chapter.content.trim().isEmpty ? 24 : 0,
), ),
sliver: SliverToBoxAdapter(
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: ConstrainedBox( child: ConstrainedBox(
@@ -958,13 +1117,30 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith(color: readerMutedColor), ?.copyWith(color: readerMutedColor),
) ),
else ],
Column( ),
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [ ),
for (var index = 0; index < paragraphs.length; index++) ),
Padding( ),
if (chapter.content.trim().isNotEmpty)
SliverPadding(
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
0,
settings.horizontalPadding,
0,
),
sliver: SliverList.builder(
itemCount: paragraphs.length,
itemBuilder: (context, index) {
final sentenceSlices = sentenceSlicesByParagraph[index];
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Padding(
key: _paragraphKeys[index], key: _paragraphKeys[index],
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1 bottom: index == paragraphs.length - 1
@@ -973,35 +1149,53 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
), ),
child: _buildParagraphText( child: _buildParagraphText(
context: context, context: context,
paragraph: paragraphs[index], sentenceSlices: sentenceSlices,
textAlign: textAlign, textAlign: textAlign,
style: TextStyle( style: paragraphStyle,
color: readerTextColor, highlightStyle: paragraphHighlightStyle,
fontSize: settings.fontSize,
height: settings.lineHeight,
letterSpacing: settings.letterSpacing,
fontFamily: settings.fontFamily == 'serif'
? 'Georgia'
: settings.fontFamily == 'mono'
? 'Courier'
: null,
),
isActiveParagraph: shouldHighlightTts && isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index, tts.activeParagraphIndex == index,
highlightStart: tts.progressStart, highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd, 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,
);
},
),
),
),
);
},
),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(
settings.horizontalPadding,
40,
settings.horizontalPadding,
92,
),
sliver: SliverToBoxAdapter(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: _NavButtons(
chapter: chapter,
onGoPrevious: () => _goToPreviousChapter(chapter),
onGoNext: () => _goToNextChapter(chapter),
),
),
),
), ),
), ),
], ],
), ),
const SizedBox(height: 40),
_NavButtons(chapter: chapter),
const SizedBox(height: 92),
],
),
),
),
),
), ),
), ),
), ),
@@ -1013,13 +1207,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}, },
), ),
floatingActionButton: chapterAsync.hasValue floatingActionButton: chapterAsync.hasValue
? AnimatedSlide( ? ValueListenableBuilder<bool>(
valueListenable: _showQuickActions,
builder: (context, showQuickActions, _) {
return AnimatedSlide(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
curve: Curves.easeOut, curve: Curves.easeOut,
offset: _showQuickActions ? Offset.zero : const Offset(0, 1.4), offset: showQuickActions ? Offset.zero : const Offset(0, 1.4),
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 140), duration: const Duration(milliseconds: 140),
opacity: _showQuickActions ? 1 : 0, opacity: showQuickActions ? 1 : 0,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final chapter = chapterAsync.value!; final chapter = chapterAsync.value!;
@@ -1043,6 +1240,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}, },
), ),
), ),
);
},
) )
: null, : null,
bottomNavigationBar: chapterAsync.whenOrNull( bottomNavigationBar: chapterAsync.whenOrNull(
@@ -1070,6 +1269,18 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
} }
} }
class _SentenceSlice {
const _SentenceSlice({
required this.text,
required this.start,
required this.end,
});
final String text;
final int start;
final int end;
}
class _TopBar extends StatelessWidget { class _TopBar extends StatelessWidget {
final String title; final String title;
final double progress; final double progress;
@@ -1339,20 +1550,25 @@ class _TabLabel extends StatelessWidget {
} }
} }
class _NavButtons extends ConsumerWidget { class _NavButtons extends StatelessWidget {
final ChapterModel chapter; final ChapterModel chapter;
const _NavButtons({required this.chapter}); final VoidCallback onGoPrevious;
final VoidCallback onGoNext;
const _NavButtons({
required this.chapter,
required this.onGoPrevious,
required this.onGoNext,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Row( return Row(
children: [ children: [
if (chapter.prevChapterId != null) if (chapter.prevChapterId != null)
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => context.pushReplacement( onPressed: onGoPrevious,
RouteNames.readerChapter(chapter.prevChapterId!),
),
icon: const Icon(Icons.chevron_left), icon: const Icon(Icons.chevron_left),
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'), label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
), ),
@@ -1362,9 +1578,7 @@ class _NavButtons extends ConsumerWidget {
if (chapter.nextChapterId != null) if (chapter.nextChapterId != null)
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () => context.pushReplacement( onPressed: onGoNext,
RouteNames.readerChapter(chapter.nextChapterId!),
),
icon: const Icon(Icons.chevron_right), icon: const Icon(Icons.chevron_right),
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'), label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
), ),
@@ -30,7 +30,7 @@ class TtsPlayerWidget extends ConsumerWidget {
final tts = ref.watch(ttsProvider); final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier); 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 { Future<void> start() async {
if (tts.status == TtsStatus.paused) { if (tts.status == TtsStatus.paused) {
@@ -61,6 +61,21 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
scrollOffset: 0, scrollOffset: 0,
); );
} }
void resetCurrentChapterProgress() {
if (state == null) return;
state = ReadingProgress(
novelId: state!.novelId,
chapterId: state!.chapterId,
chapterNumber: state!.chapterNumber,
scrollOffset: 0,
);
// Persist immediately so a freshly opened chapter always resumes at top.
unawaited(_persistProgress(state!.chapterId, state!.chapterNumber, 0));
}
void updateScroll(double offset) { void updateScroll(double offset) {
if (state == null) return; if (state == null) return;
state = ReadingProgress( state = ReadingProgress(
+500 -115
View File
@@ -7,7 +7,7 @@ import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused } enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45; const double kTtsBaseSpeechRate = 0.9;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate; double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
@@ -32,6 +32,13 @@ class _TtsSegment {
final int paragraphIndex; final int paragraphIndex;
final int start; final int start;
final int end; final int end;
Map<String, Object?> toMap() => {
'text': text,
'paragraphIndex': paragraphIndex,
'start': start,
'end': end,
};
} }
class TtsVoice { class TtsVoice {
@@ -65,7 +72,7 @@ class TtsState {
this.paragraphIndex = 0, this.paragraphIndex = 0,
this.totalParagraphs = 0, this.totalParagraphs = 0,
this.activeParagraphIndex = -1, this.activeParagraphIndex = -1,
this.speed = 0.45, this.speed = 0.9,
this.language = 'vi-VN', this.language = 'vi-VN',
this.voiceName, this.voiceName,
this.availableVietnameseVoices = const [], this.availableVietnameseVoices = const [],
@@ -124,17 +131,40 @@ class TtsState {
} }
class TtsNotifier extends StateNotifier<TtsState> { 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()) { TtsNotifier() : super(const TtsState()) {
_initFuture = _init(); _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 { Future<void> _init() async {
if (_useNativeAndroidMediaService) {
await _initAndroidBridge();
_initialized = true;
return;
}
await _tts.awaitSpeakCompletion(true); await _tts.awaitSpeakCompletion(true);
await _tts.setSharedInstance(true); await _tts.setSharedInstance(true);
@@ -150,39 +180,85 @@ class TtsNotifier extends StateNotifier<TtsState> {
); );
} }
if (Platform.isAndroid) { await _configureVietnameseVoiceWithFlutterTts();
await _tts.setAudioAttributesForNavigation();
}
await _configureVietnameseVoice();
await _tts.setSpeechRate(kTtsBaseSpeechRate); await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0); await _tts.setVolume(1.0);
await _tts.setPitch(1.0); await _tts.setPitch(1.0);
_tts.setStartHandler(() { _tts.setStartHandler(() {
_didStartCurrentFallbackUtterance = true;
final index = _pendingFallbackIndex;
if (index >= 0 && index < _segments.length) {
final segment = _segments[index];
state = state.copyWith( state = state.copyWith(
status: TtsStatus.playing, status: TtsStatus.playing,
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
); );
} else {
state = state.copyWith(status: TtsStatus.playing);
}
unawaited(_syncBackgroundMode()); unawaited(_syncBackgroundMode());
}); });
_tts.setCompletionHandler(() { _tts.setCompletionHandler(() {
if (state.status == TtsStatus.playing) { // Fallback playback progression is driven by _playFallbackFromGeneration.
_next();
}
}); });
_tts.setErrorHandler((msg) { _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()); unawaited(_syncBackgroundMode());
}); });
await _syncBackgroundMode(); await _syncBackgroundMode();
_initialized = true; _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; final dynamic voicesRaw = await _tts.getVoices;
String? selectedName; String? selectedName;
@@ -191,22 +267,34 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (voicesRaw is List) { if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) { 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'); return locale.startsWith('vi');
}).toList(); }).toList();
for (final voice in vietnamese) { for (final voice in vietnamese) {
final name = voice['name']?.toString(); final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.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)); vietnameseVoices.add(TtsVoice(name: name, locale: locale));
} }
if (vietnamese.isNotEmpty) { if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere( final preferred = vietnamese.firstWhere(
(voice) => (voice) =>
(voice['name']?.toString().toLowerCase().contains('female') ?? false) || (voice['name']
(voice['name']?.toString().toLowerCase().contains('natural') ?? false), ?.toString()
.toLowerCase()
.contains('female') ??
false) ||
(voice['name']
?.toString()
.toLowerCase()
.contains('natural') ??
false),
orElse: () => vietnamese.first, orElse: () => vietnamese.first,
); );
selectedName = preferred['name']?.toString(); selectedName = preferred['name']?.toString();
@@ -219,6 +307,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) { if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage}); await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
} }
state = state.copyWith( state = state.copyWith(
language: selectedLanguage, language: selectedLanguage,
voiceName: selectedName, 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 { 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); final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return; if (selected.isEmpty) return;
@@ -240,6 +498,16 @@ class TtsNotifier extends StateNotifier<TtsState> {
Future<void> setBackgroundModeEnabled(bool enabled) async { Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled); state = state.copyWith(backgroundModeEnabled: enabled);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setBackgroundModeEnabled', {
'enabled': enabled,
});
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
return;
}
await _syncBackgroundMode(); await _syncBackgroundMode();
if (enabled) { if (enabled) {
await ensureBatteryOptimizationIgnored(); await ensureBatteryOptimizationIgnored();
@@ -278,23 +546,24 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> _syncBackgroundMode() async { Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return; if (_useNativeAndroidMediaService || !Platform.isAndroid) return;
final shouldKeepAlive = final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing; state.backgroundModeEnabled && state.status == TtsStatus.playing;
try { try {
await _backgroundChannel await _backgroundChannel.invokeMethod<void>('setWakeLock', {
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive}); 'enabled': shouldKeepAlive,
});
} catch (_) { } catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable. // Keep playback functional even if native wake lock bridge is unavailable.
} }
} }
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading( Future<void> startReading(
String content, { String content, {
int paragraphIndex = 0, int paragraphIndex = 0,
int? startParagraphIndex, int? startParagraphIndex,
int? startCharOffset,
String? contentKey, String? contentKey,
String? title, String? title,
bool includeTitle = true, bool includeTitle = true,
@@ -303,57 +572,38 @@ class TtsNotifier extends StateNotifier<TtsState> {
await (_initFuture ?? _init()); await (_initFuture ?? _init());
} }
final segments = <_TtsSegment>[]; _segments = _buildSegments(
content,
final titleText = title?.trim(); title: title,
if (includeTitle && titleText != null && titleText.isNotEmpty) { includeTitle: includeTitle,
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) {
if (_segments.isEmpty) return; state = state.copyWith(
status: TtsStatus.idle,
var validIndex = paragraphIndex.clamp(0, _segments.length - 1); paragraphIndex: 0,
if (startParagraphIndex != null) { totalParagraphs: 0,
final startFromVisible = _segments.indexWhere( activeParagraphIndex: -1,
(segment) => segment.paragraphIndex >= startParagraphIndex, progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
); );
if (startFromVisible >= 0) { if (!_useNativeAndroidMediaService) {
validIndex = startFromVisible; await _syncBackgroundMode();
} }
return;
} }
final validIndex = _resolveStartIndex(
paragraphIndex,
startParagraphIndex: startParagraphIndex,
startCharOffset: startCharOffset,
);
final selectedSegment = _segments[validIndex]; final selectedSegment = _segments[validIndex];
if (_useNativeAndroidMediaService) {
await _ensureAndroidMediaNotificationsEnabled();
state = state.copyWith( state = state.copyWith(
status: TtsStatus.playing, status: TtsStatus.playing,
paragraphIndex: validIndex, paragraphIndex: validIndex,
@@ -363,12 +613,91 @@ class TtsNotifier extends StateNotifier<TtsState> {
progressEnd: selectedSegment.end, progressEnd: selectedSegment.end,
contentKey: contentKey, contentKey: contentKey,
); );
await _syncBackgroundMode();
await _speak(validIndex); 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;
} }
Future<void> _speak(int index) async { final sessionId = await _interruptFallbackPlayback();
if (index >= _segments.length) {
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _segments.length,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
contentKey: contentKey,
);
await _syncBackgroundMode();
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
}
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;
}
return _playbackGeneration;
}
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,
);
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( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
activeParagraphIndex: -1, activeParagraphIndex: -1,
@@ -379,21 +708,23 @@ class TtsNotifier extends StateNotifier<TtsState> {
return; return;
} }
final segment = _segments[index]; if (!_didStartCurrentFallbackUtterance) {
state = state.copyWith( state = state.copyWith(
paragraphIndex: index, status: TtsStatus.idle,
activeParagraphIndex: segment.paragraphIndex, activeParagraphIndex: -1,
progressStart: segment.start, progressStart: -1,
progressEnd: segment.end, progressEnd: -1,
); );
await _syncBackgroundMode();
await _tts.setSpeechRate(state.speed); return;
await _tts.speak(segment.text); }
} }
Future<void> _next() async { if (generation != _playbackGeneration) return;
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) { _pendingFallbackIndex = -1;
_didStartCurrentFallbackUtterance = false;
state = state.copyWith( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
@@ -403,33 +734,66 @@ class TtsNotifier extends StateNotifier<TtsState> {
completedCount: state.completedCount + 1, completedCount: state.completedCount + 1,
); );
await _syncBackgroundMode(); await _syncBackgroundMode();
return;
}
state = state.copyWith(
paragraphIndex: next,
activeParagraphIndex: _segments[next].paragraphIndex,
progressStart: _segments[next].start,
progressEnd: _segments[next].end,
);
await _speak(next);
} }
Future<void> pause() async { Future<void> pause() async {
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('pause');
return;
}
if (state.status != TtsStatus.playing) return;
_playbackGeneration++;
await _tts.pause(); await _tts.pause();
state = state.copyWith(status: TtsStatus.paused); state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode(); await _syncBackgroundMode();
} }
Future<void> resume() async { Future<void> _restartFallbackFromIndex(int index) async {
if (state.status != TtsStatus.paused) return; if (_segments.isEmpty) return;
state = state.copyWith(status: TtsStatus.playing);
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(); 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 { 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( state = state.copyWith(
status: TtsStatus.idle, status: TtsStatus.idle,
paragraphIndex: 0, paragraphIndex: 0,
@@ -442,32 +806,53 @@ class TtsNotifier extends StateNotifier<TtsState> {
} }
Future<void> skipForward() async { Future<void> skipForward() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
await _next(); 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 { Future<void> skipBack() async {
await _tts.stop(); if (_useNativeAndroidMediaService) {
if (state.totalParagraphs <= 0) return; await _mediaChannel.invokeMethod<void>('skipBack');
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1); return;
state = state.copyWith( }
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex, if (_segments.isEmpty || state.totalParagraphs <= 0) return;
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end, final prev = (state.paragraphIndex - 1).clamp(0, _segments.length - 1);
); await _restartFallbackFromIndex(prev);
if (state.status == TtsStatus.playing) await _speak(prev);
} }
Future<void> setSpeed(double speed) async { Future<void> setSpeed(double speed) async {
state = state.copyWith(speed: speed); state = state.copyWith(speed: speed);
if (_useNativeAndroidMediaService) {
await _mediaChannel.invokeMethod<void>('setSpeed', {'speed': speed});
return;
}
await _tts.setSpeechRate(speed); await _tts.setSpeechRate(speed);
} }
@override @override
void dispose() { void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false})); _mediaEventsSub?.cancel();
_tts.stop(); if (_useNativeAndroidMediaService) {
unawaited(_mediaChannel.invokeMethod<void>('dispose'));
} else {
unawaited(_tts.stop());
}
super.dispose(); super.dispose();
} }
} }
@@ -10,7 +10,18 @@ import '../../novel/providers/novels_provider.dart';
import '../../genres/providers/genres_provider.dart'; import '../../genres/providers/genres_provider.dart';
class SearchScreen extends ConsumerStatefulWidget { 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 @override
ConsumerState<SearchScreen> createState() => _SearchScreenState(); ConsumerState<SearchScreen> createState() => _SearchScreenState();
@@ -18,6 +29,7 @@ class SearchScreen extends ConsumerStatefulWidget {
class _SearchScreenState extends ConsumerState<SearchScreen> { class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
String? _selectedGenre; String? _selectedGenre;
String? _selectedStatus; String? _selectedStatus;
@@ -35,13 +47,61 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
('Tên A-Z', 'name'), ('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 @override
void dispose() { void dispose() {
_scrollController
..removeListener(_onScroll)
..dispose();
_controller.dispose(); _controller.dispose();
_debounce?.cancel(); _debounce?.cancel();
super.dispose(); 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) { void _onQueryChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), _applyFilters); _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 const Center(child: Text('Không tìm thấy truyện'));
} }
return ListView.builder( return ListView.builder(
itemCount: result.items.length, controller: _scrollController,
itemBuilder: (context, index) => _NovelListTile(novel: result.items[index]), 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,6 +276,7 @@ class _FilterChipDropdown extends StatelessWidget {
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: onSelected, onSelected: onSelected,
itemBuilder: (_) => items, itemBuilder: (_) => items,
child: IgnorePointer(
child: FilterChip( child: FilterChip(
label: Text(label), label: Text(label),
selected: selected, selected: selected,
@@ -206,6 +284,7 @@ class _FilterChipDropdown extends StatelessWidget {
deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null, deleteIcon: selected && onClear != null ? const Icon(Icons.close, size: 14) : null,
onDeleted: selected ? onClear : null, onDeleted: selected ? onClear : null,
), ),
),
); );
} }
} }
+56
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -57,6 +65,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -190,6 +214,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -360,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.8.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +416,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.7" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -560,6 +608,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+11 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 1.0.1+2
environment: environment:
sdk: ^3.11.3 sdk: ^3.11.3
@@ -56,6 +56,16 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec