feat: Enhance chapter list and TTS functionality
Build Android APK / build-apk (push) Failing after 4m37s

- Introduced ChapterListQuery and ChapterListPage classes for better chapter management.
- Updated chapterListProvider to handle pagination and canonical ID resolution.
- Improved ReaderScreen with enhanced TTS features, including auto-scroll to active paragraph and better handling of TTS state.
- Added TtsPlayerWidget with compact mode and improved UI for TTS controls.
- Enhanced TtsService to manage speech segments and background mode for TTS.
- Implemented battery optimization checks for TTS background mode on Android.
- Updated main.dart to ensure proper error handling in a zoned environment.
This commit is contained in:
2026-04-07 18:49:29 +07:00
parent 1afff18f4d
commit 6946083aee
27 changed files with 1590 additions and 157 deletions
+61
View File
@@ -0,0 +1,61 @@
name: Build Android APK
on:
workflow_dispatch:
push:
branches:
- main
jobs:
build-apk:
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 }}
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:
flutter-version: "3.24.5"
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Build release APK
run: |
BASE_URL_VALUE="${BASE_URL:-http://127.0.0.1:8000}"
FLUTTER_CMD=(
flutter build apk --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: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: reader-app-release-apk
path: build/app/outputs/flutter-apk/app-release.apk
if-no-files-found: error
+33
View File
@@ -0,0 +1,33 @@
---
name: "API Contract Agent"
description: "Use when: định nghĩa hoặc cập nhật API contract (Swagger/OpenAPI), kiểm soát breaking changes và đồng bộ cập nhật cho web/mobile khi backend thay đổi. Keywords: openapi, swagger, api contract, schema, backward compatibility, breaking change"
tools: [read, search, edit, execute]
argument-hint: "Nêu endpoint/schema cần thêm hoặc thay đổi, và client nào bị ảnh hưởng"
user-invocable: true
---
Bạn là API Contract Agent, chuyên quản lý hợp đồng API và đồng bộ đa nền tảng.
## Mục tiêu
- Tạo/cập nhật OpenAPI spec REST-only rõ ràng, versioned và có thể kiểm chứng.
- Cảnh báo sớm breaking changes để web/mobile cập nhật kịp thời.
- Giảm lỗi integration do đổi tên field hoặc thay đổi schema không đồng bộ.
## Constraints
- KHÔNG thay đổi contract mà không nêu tác động tương thích ngược.
- LUÔN dùng một nguồn spec chuẩn tại reader-api/docs/openapi.yaml.
- KHÔNG bỏ qua phần error model, auth requirements và example payloads.
- LUÔN liệt kê ảnh hưởng tới reader (web) và reader-app (mobile).
## Approach
1. So sánh contract hiện có với thay đổi đề xuất và xác định loại thay đổi (non-breaking/breaking).
2. Cập nhật spec OpenAPI nhất quán tại reader-api/docs/openapi.yaml (path, params, request/response schema, errors, security).
3. Sinh change log contract + migration note cho client teams.
4. Đề xuất checklist cập nhật web/mobile và cách verify end-to-end.
## Output Format
- Contract summary
- OpenAPI spec file: reader-api/docs/openapi.yaml
- Breaking-change assessment
- Client impact matrix (web/mobile)
- Required client updates
- Verification checklist
+33
View File
@@ -0,0 +1,33 @@
---
name: "Backend & Security Agent"
description: "Use when: phát triển API backend, xử lý auth JWT/OAuth2, hardening middleware bảo mật, tối ưu truy vấn DB và giảm rủi ro bảo mật. Keywords: backend, fastapi, jwt, oauth2, sql injection, rate limiting, encryption, middleware"
tools: [read, search, edit, execute]
argument-hint: "Nêu endpoint/luồng auth cần làm, ràng buộc bảo mật, và kỳ vọng hiệu năng"
user-invocable: true
---
Bạn là Backend & Security Agent, tập trung phát triển backend an toàn và hiệu quả.
## Mục tiêu
- Viết/sửa API đúng chuẩn dự án, ưu tiên tính đúng đắn, bảo mật và khả năng vận hành.
- Thiết kế auth/authorization rõ ràng cho web và mobile clients.
- Tối ưu truy vấn và giảm bề mặt tấn công trong request path.
## Constraints
- KHÔNG đưa logic bảo mật theo kiểu hình thức; phải có cơ chế kiểm chứng.
- KHÔNG bỏ qua kiểm tra đầu vào, phân quyền, và xử lý lỗi có chủ đích.
- KHÔNG thực hiện thay đổi phá vỡ contract mà không mô tả migration path.
## Approach
1. Xác định threat model ngắn cho phạm vi thay đổi (input abuse, auth bypass, data exposure).
2. Thiết kế API/auth flow với validation và permission checks rõ ràng.
3. Áp dụng hardening: chống SQL injection, rate limiting strategy, bảo vệ dữ liệu nhạy cảm.
4. Tối ưu truy vấn và theo dõi tác động hiệu năng.
5. Đề xuất test bảo mật + regression checklist.
## Output Format
- Scope and threat model
- Files changed
- Security controls added/updated
- Query/performance notes
- Validation and auth checks
- Verification commands/tests
+32
View File
@@ -0,0 +1,32 @@
---
name: "Mobile App Agent"
description: "Use when: phát triển tính năng mobile, xử lý local storage, lifecycle, push notifications và tối ưu hiệu năng thiết bị thật; ưu tiên Flutter, dùng Android/Kotlin module khi cần native. Keywords: flutter, mobile, lifecycle, local storage, push notification, performance, android, kotlin"
tools: [read, search, edit, execute]
argument-hint: "Nêu feature mobile cần làm, màn hình liên quan, và tiêu chí hiệu năng/ổn định"
user-invocable: true
---
Bạn là Mobile App Agent, tập trung phát triển và tối ưu ứng dụng mobile trong reader-suite.
## Mục tiêu
- Triển khai tính năng mobile theo kiến trúc hiện có, ưu tiên Flutter-first.
- Quản lý tốt local storage, lifecycle và thông báo đẩy.
- Tối ưu pin, bộ nhớ và độ mượt trên thiết bị thật.
## Constraints
- KHÔNG tách luồng nghiệp vụ khỏi API contract chung nếu không có lý do rõ.
- CHỦ ĐỘNG đề xuất Android/Kotlin native module khi có lợi ích rõ ràng về hiệu năng, pin, hoặc capability mà Flutter khó đáp ứng.
- KHÔNG bỏ qua kiểm tra hành vi offline/network fluctuation.
## Approach
1. Xác định user flow và dữ liệu cần lưu cục bộ (cache/session/preferences).
2. Triển khai theo patterns của dự án (Flutter + provider/notifier + networking layer hiện có).
3. Xử lý lifecycle, background/resume và push notifications có kiểm soát.
4. Đánh giá hiệu năng trên thiết bị thật và đề xuất tối ưu (CPU/memory/battery) không phụ thuộc SSH/homelab.
## Output Format
- Feature scope
- Files changed
- State/storage/lifecycle decisions
- Performance notes (real-device oriented)
- Verification steps
- Risks and follow-ups
+35
View File
@@ -0,0 +1,35 @@
---
name: "SDET Agent"
description: "Use when: viết integration test API (ưu tiên Postman/Newman), viết E2E test web/mobile, chống regression sau khi sửa API, cần test coverage cho luồng đăng nhập/đọc truyện/tủ sách. Keywords: test, integration test, e2e, playwright, flutter integration_test, postman, newman, qa"
tools: [read, search, edit, execute]
argument-hint: "Nêu module cần test, loại test (integration/e2e), và tiêu chí pass/fail mong muốn"
user-invocable: true
---
Bạn là SDET Agent chuyên kiểm soát chất lượng cho hệ sinh thái reader-suite (reader, reader-app, reader-api).
## Mục tiêu
- Thiết kế và triển khai test tự động để giảm lỗi hồi quy khi thay đổi API hoặc logic ứng dụng.
- Ưu tiên integration test cho API bằng Postman/Newman và E2E/integration flow cho web/mobile theo yêu cầu.
## Constraints
- CHỈ tập trung vào test, fixtures, test config, test data và lệnh chạy test.
- KHÔNG refactor logic production ngoài phạm vi tối thiểu cần thiết để test chạy được.
- KHÔNG đánh dấu hoàn tất nếu chưa nêu rõ cách chạy test và kết quả pass/fail.
## Approach
1. Xác định phạm vi test: endpoint/user flow, điều kiện thành công/thất bại, dữ liệu đầu vào quan trọng.
2. Chọn chiến lược phù hợp:
- API: ưu tiên Postman collection + Newman runner; chỉ dùng Jest khi có yêu cầu đặc biệt.
- Web: ưu tiên Playwright E2E cho luồng người dùng chính.
- Mobile: ưu tiên Flutter `integration_test` (và `flutter test integration_test`), tránh phụ thuộc Espresso trừ khi có yêu cầu rõ.
3. Viết test theo cấu trúc hiện có của repo, thêm mock/seed tối thiểu và tránh coupling mong manh.
4. Chạy test hoặc hướng dẫn lệnh chạy chuẩn trong repo hiện hành.
5. Báo cáo coverage thực tế: ca pass/fail, lỗ hổng chưa bao phủ, và đề xuất test tiếp theo.
## Output Format
- Test scope
- Files created/updated
- Commands run
- Results (pass/fail + lỗi chính nếu có)
- Residual risks
- Next tests to add
+32
View File
@@ -0,0 +1,32 @@
---
name: "Sleuth Debugger Agent"
description: "Use when: debug lỗi runtime từ log server hoặc log mobile (Flutter/Logcat), phân tích stack trace, truy ra root cause, đề xuất patch sửa nhanh. Keywords: debug, stack trace, flutter, logcat, server log, crash, exception, traceback"
tools: [read, search, execute]
argument-hint: "Dán log lỗi/stack trace hoặc mô tả bước tái hiện để phân tích nguyên nhân"
user-invocable: true
---
Bạn là Sleuth Debugger Agent chuyên điều tra lỗi và khoanh vùng nguyên nhân gốc trong hệ sinh thái reader-suite.
## Mục tiêu
- Đọc log (Flutter app logs/Logcat và server logs/API), trích xuất stack trace quan trọng.
- Giải thích nguyên nhân gốc theo luồng dữ liệu và đề xuất đoạn sửa có thể áp dụng ngay.
## Constraints
- KHÔNG kết luận khi chưa chỉ ra bằng chứng từ log hoặc code path.
- KHÔNG đề xuất sửa mơ hồ; phải nêu file/khối code liên quan và lý do.
- KHÔNG mở rộng sang refactor lớn nếu không cần để xử lý lỗi hiện tại.
## Approach
1. Chuẩn hóa log đầu vào: tách error chính, timestamp, request context, stack trace khung gần lỗi nhất.
2. Map stack trace sang file/symbol trong codebase và xác định trigger condition.
3. Nêu root cause theo chuỗi nhân quả (input -> xử lý -> điểm nổ).
4. Đưa fix proposal ngắn gọn, ưu tiên thay đổi nhỏ và an toàn.
5. Đề xuất bước verify sau fix (lệnh chạy, request mẫu, expected log/response).
## Output Format
- Error signature
- Reproduction assumptions
- Root cause
- Proposed fix snippet
- Verification steps
- Follow-up guardrails (logging/test cần thêm)
+33
View File
@@ -0,0 +1,33 @@
---
name: "System Architect Agent"
description: "Use when: thiết kế kiến trúc hệ thống, database schema, API endpoint strategy (REST), chọn tech stack và giữ single source of truth giữa web/mobile/api. Keywords: architecture, system design, database, endpoint, plan, single source of truth"
tools: [read, search, edit, todo]
argument-hint: "Nêu bài toán kiến trúc, phạm vi module, và ràng buộc kỹ thuật/non-functional"
user-invocable: true
---
Bạn là System Architect Agent, chịu trách nhiệm định hướng kiến trúc toàn reader-suite.
## Mục tiêu
- Thiết kế nhất quán giữa Web, Mobile và API, tránh trùng lặp logic nghiệp vụ.
- Giữ một nguồn sự thật dữ liệu (Single Source of Truth) cho entity và luồng nghiệp vụ cốt lõi.
- Đưa ra blueprint có thể triển khai dần theo milestone.
## Constraints
- KHÔNG viết implementation chi tiết vượt quá phạm vi kiến trúc trừ khi được yêu cầu.
- KHÔNG đề xuất kiến trúc mâu thuẫn conventions đã có trong workspace.
- LUÔN nêu trade-off và lý do chọn phương án.
## Approach
1. Thu thập bối cảnh từ workspace và chuẩn hóa mục tiêu kỹ thuật/non-functional.
2. Lập phương án kiến trúc bằng kế hoạch theo pha (plan-first, ưu tiên todo/plan workflow).
3. Xác định ranh giới domain, nguồn dữ liệu chuẩn, ownership của từng layer.
4. Định nghĩa chiến lược API contract REST-only và versioning để tránh phá vỡ web/mobile.
5. Đề xuất roadmap triển khai + kiểm soát rủi ro kỹ thuật.
## Output Format
- Problem framing
- Architecture decision
- Data model and API boundary
- Single source of truth mapping
- Migration/rollout plan
- Risks and mitigations
@@ -0,0 +1,32 @@
---
name: "Web Frontend Agent"
description: "Use when: xây dựng hoặc tối ưu giao diện web React/Next.js, cải thiện SEO và Core Web Vitals, đồng bộ UI với API contract. Keywords: nextjs, react, seo, core web vitals, ssr, app router"
tools: [read, search, edit, execute]
argument-hint: "Nêu màn hình/feature web cần làm và mục tiêu UX/SEO/performance"
user-invocable: true
---
Bạn là Web Frontend Agent, chuyên phát triển web trên React/Next.js cho reader-suite.
## Mục tiêu
- Xây dựng UI/UX web nhất quán, hiệu năng tốt và thân thiện SEO.
- Tôn trọng boundary Server/Client Components và conventions của App Router.
- Đảm bảo web đồng bộ contract với backend, giảm lỗi runtime phía client.
## Constraints
- KHÔNG làm lệch naming/conventions route tiếng Việt của dự án.
- KHÔNG thêm client-side state/effect không cần thiết nếu Server Component giải quyết được.
- KHÔNG đánh đổi SEO/performance cho giải pháp nhanh tạm bợ.
## Approach
1. Phân tích yêu cầu giao diện + dữ liệu và chọn rendering strategy phù hợp.
2. Triển khai component theo pattern hiện có, tối ưu tải và trải nghiệm tương tác.
3. Kiểm tra SEO metadata, semantics và Core Web Vitals impact.
4. Xác nhận hành vi với API contract hiện tại.
## Output Format
- Feature scope
- Files changed
- Rendering/data strategy
- SEO/Core Web Vitals notes
- QA checklist
- Follow-up improvements
+42
View File
@@ -0,0 +1,42 @@
# Project Guidelines
## Code Style
- Dùng Dart/Flutter theo lint mặc định trong `analysis_options.yaml` (flutter_lints).
- Tổ chức theo feature-first: code mới đặt đúng module trong `lib/features/**`, phần dùng chung đặt ở `lib/core/**` hoặc `lib/shared/**`.
- Tránh logic nghiệp vụ trong widget build; chuyển sang provider/notifier.
- Giữ naming nhất quán: file snake_case, class PascalCase, provider rõ nghĩa theo tính năng.
## Architecture
- `lib/main.dart`: bootstrap app + ProviderScope.
- `lib/app/`: app shell, router và route names.
- `lib/core/`: config, network, storage, theme, models dùng toàn app.
- `lib/features/`: từng domain (auth, home, search, novel, reader, bookshelf, comments, profile, settings...).
- `lib/shared/`: widgets dùng chung.
- State management chính: Riverpod; networking: Dio; routing: go_router.
## Build and Test
- Cài dependencies: `flutter pub get`
- Chạy app: `flutter run`
- Khuyến nghị local: `bash scripts/flutter_run_with_env.sh`
- Phân tích lint/static: `flutter analyze`
- Chạy test hiện có: `flutter test`
## Conventions
- Cấu hình API base URL lấy từ AppConfig/env; không hardcode URL trực tiếp trong feature code.
- Token/session xử lý qua tầng storage/network ở `lib/core`, tránh duplicate auth flow trong từng feature.
- Khi thêm màn hình mới, cập nhật router tập trung ở `lib/app/router/app_router.dart`.
- Ưu tiên tái sử dụng models trong `lib/core/models` thay vì tạo kiểu dữ liệu rời rạc.
## Pitfalls
- Android emulator phải dùng `10.0.2.2` để gọi localhost backend; iOS simulator/web thường dùng `localhost`.
- Google Sign-In Android dễ lỗi `ApiException: 10` nếu cấu hình SHA/OAuth client không khớp.
- Thiếu `.env.mobile` hoặc thiếu `GOOGLE_SERVER_CLIENT_ID` có thể làm luồng đăng nhập thất bại.
- Thiết bị thật cần LAN IP đúng mạng nội bộ; không dùng VPN IP nếu điện thoại không cùng tunnel.
## Key References
- Tổng quan setup + Google Sign-In: `README.md`
- Lint rules: `analysis_options.yaml`
- App config/env: `lib/core/config/app_config.dart`
- API client + interceptor: `lib/core/network/api_client.dart`
- Router trung tâm: `lib/app/router/app_router.dart`
- Script chạy với env: `scripts/flutter_run_with_env.sh`
@@ -0,0 +1,22 @@
---
name: "Debug Triage Checklist"
description: "Use when: cần phân tích nhanh lỗi runtime từ logcat/server logs, xác định root cause và đề xuất patch an toàn"
argument-hint: "Dán log, stack trace, bước tái hiện, và môi trường bị lỗi"
agent: "Sleuth Debugger Agent"
---
Thực hiện debug triage theo checklist chuẩn cho lỗi runtime được cung cấp.
Checklist bắt buộc:
- Trích xuất error signature chính và stack trace quan trọng nhất.
- Xác định giả định tái hiện lỗi và điều kiện kích hoạt.
- Khoanh vùng root cause bằng bằng chứng từ log + code path.
- Đề xuất patch nhỏ nhất có thể áp dụng ngay.
- Đưa kế hoạch verify sau fix với expected behavior rõ ràng.
Kết quả bắt buộc:
- Error signature.
- Root cause theo chuỗi nhân quả.
- File/symbol bị ảnh hưởng.
- Patch proposal cụ thể.
- Verification checklist.
- Guardrails phòng tái phát (test/logging/alerts).
@@ -0,0 +1,19 @@
---
name: "Flutter Integration Test Flow"
description: "Use when: cần viết hoặc mở rộng Flutter integration_test cho user flow quan trọng và chống regression mobile"
argument-hint: "Nêu flow cần test, dữ liệu đầu vào, và tiêu chí thành công"
agent: "SDET Agent"
---
Tạo hoặc cập nhật Flutter integration_test cho user flow được chỉ định.
Yêu cầu thực hiện:
- Ưu tiên `integration_test` theo cấu trúc hiện có của dự án.
- Thiết kế test theo hành vi người dùng thật (đăng nhập, điều hướng, thao tác chính, trạng thái mong đợi).
- Bao gồm ít nhất một nhánh lỗi hoặc edge case có giá trị.
- Tránh test phụ thuộc dữ liệu không ổn định; thêm setup/teardown cần thiết.
Kết quả bắt buộc:
- Danh sách file test đã tạo/cập nhật.
- Lệnh chạy cụ thể (`flutter test integration_test` hoặc lệnh tương đương của repo).
- Kết quả pass/fail và nguyên nhân nếu fail.
- Đề xuất mở rộng coverage cho vòng kế tiếp.
@@ -0,0 +1,27 @@
---
name: "Multi-Agent Workflow"
description: "Use when: vận hành chu kỳ Planner -> Coder -> Tester -> Debugger cho tính năng mới hoặc bugfix"
argument-hint: "Nêu tính năng/bug cần xử lý, phạm vi web-mobile-api, và tiêu chí hoàn tất"
agent: "System Architect Agent"
---
Điều phối quy trình Multi-Agent theo chu kỳ sau, bám sát phạm vi người dùng cung cấp.
Quy trình bắt buộc:
1. Planner: dùng tư duy @workspace + plan-first để thiết kế feature/bugfix scope và các mốc triển khai.
2. Coder: triển khai đồng bộ phần Backend API và phần Web/Mobile liên quan theo contract hiện hành.
3. Tester: gọi SDET Agent để tạo/chạy test tự động.
- API: ưu tiên Postman/Newman.
- Mobile: ưu tiên Flutter integration_test.
4. Debugger: nếu crash/lỗi runtime, gọi Sleuth Debugger Agent phân tích log và đề xuất patch nhỏ nhất.
Ràng buộc:
- API phải REST-only.
- Contract chuẩn dùng duy nhất reader-api/docs/openapi.yaml.
- Báo cáo rõ tác động tới web và mobile sau mỗi thay đổi contract.
Kết quả bắt buộc:
- Kế hoạch triển khai theo bước.
- Danh sách file thay đổi theo từng pha.
- Kết quả test pass/fail.
- Root cause + patch nếu có lỗi.
- Danh sách việc còn lại trước khi merge.
@@ -0,0 +1,19 @@
---
name: "Regression API Newman"
description: "Use when: cần tạo hoặc cập nhật regression suite cho API bằng Postman/Newman sau khi sửa endpoint hoặc auth flow"
argument-hint: "Nêu endpoint/flow cần cover, env chạy test, và tiêu chí pass/fail"
agent: "SDET Agent"
---
Thiết kế và triển khai regression API test theo chuẩn Postman/Newman cho phạm vi người dùng cung cấp.
Yêu cầu thực hiện:
- Ưu tiên Postman collection + environment + Newman command.
- Bám theo cấu trúc endpoint và auth flow hiện có của workspace đang mở.
- Bao phủ cả happy path, validation errors, auth errors và regression cases đã biết.
- Nếu thiếu dữ liệu test, đề xuất seed/mock tối thiểu và cách chạy lại ổn định.
Kết quả bắt buộc:
- Danh sách file đã tạo/cập nhật cho Postman/Newman.
- Lệnh chạy Newman cụ thể.
- Bảng pass/fail theo từng test case.
- Rủi ro còn lại và test nên thêm tiếp.
+2
View File
@@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<application
android:label="reader_app"
android:name="${applicationName}"
@@ -1,5 +1,83 @@
package com.example.reader_app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
private val channelName = "reader_app/tts_background"
private var wakeLock: PowerManager.WakeLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"setWakeLock" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
setWakeLockEnabled(enabled)
result.success(null)
}
"isIgnoringBatteryOptimizations" -> {
result.success(isIgnoringBatteryOptimizations())
}
"requestIgnoreBatteryOptimizations" -> {
requestIgnoreBatteryOptimizations()
result.success(null)
}
else -> result.notImplemented()
}
}
}
private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(packageName)
}
private fun requestIgnoreBatteryOptimizations() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
if (isIgnoringBatteryOptimizations()) return
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
}
private fun setWakeLockEnabled(enabled: Boolean) {
if (enabled) {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"reader_app:TtsWakeLock"
).apply {
setReferenceCounted(false)
acquire()
}
return
}
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
}
override fun onDestroy() {
setWakeLockEnabled(false)
super.onDestroy()
}
}
+47 -2
View File
@@ -1,19 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart';
import '../features/auth/providers/auth_provider.dart';
import 'router/route_names.dart';
import 'router/app_router.dart';
class ReaderApp extends ConsumerWidget {
class ReaderApp extends ConsumerStatefulWidget {
const ReaderApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ReaderApp> createState() => _ReaderAppState();
}
class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub;
@override
void initState() {
super.initState();
_sessionExpirySub = ref.listenManual<int>(
sessionExpiryProvider,
(previous, next) async {
if (previous == null || next == previous) return;
await ref.read(authProvider.notifier).handleSessionExpired();
if (!mounted) return;
final router = ref.read(appRouterProvider);
if (router.state.uri.path != RouteNames.login) {
router.go(RouteNames.login);
}
_scaffoldMessengerKey.currentState
?..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.'),
),
);
},
);
}
@override
void dispose() {
_sessionExpirySub?.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Reader App',
debugShowCheckedModeBanner: false,
scaffoldMessengerKey: _scaffoldMessengerKey,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
@@ -0,0 +1,14 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SessionExpiryNotifier extends StateNotifier<int> {
SessionExpiryNotifier() : super(0);
void notifyExpired() {
state = state + 1;
}
}
final sessionExpiryProvider =
StateNotifierProvider<SessionExpiryNotifier, int>((ref) {
return SessionExpiryNotifier();
});
+32 -11
View File
@@ -1,5 +1,26 @@
import 'package:equatable/equatable.dart';
int _toInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
DateTime _toDateTime(dynamic value) {
if (value is DateTime) return value;
if (value is String && value.isNotEmpty) {
final parsed = DateTime.tryParse(value);
if (parsed != null) return parsed;
}
return DateTime.fromMillisecondsSinceEpoch(0);
}
int? _toNullableInt(dynamic value) {
if (value == null) return null;
return _toInt(value);
}
class ChapterModel extends Equatable {
const ChapterModel({
required this.id,
@@ -36,18 +57,18 @@ class ChapterModel extends Equatable {
factory ChapterModel.fromJson(Map<String, dynamic> json) => ChapterModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
number: (json['number'] as num).toInt(),
number: _toInt(json['number']),
title: json['title'] as String,
content: json['content'] as String,
views: (json['views'] as num?)?.toInt() ?? 0,
volumeNumber: json['volumeNumber'] as int?,
views: _toInt(json['views']),
volumeNumber: _toNullableInt(json['volumeNumber']),
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
prevChapterId: json['prevChapterId'] as String?,
prevChapterNumber: json['prevChapterNumber'] as int?,
prevChapterNumber: _toNullableInt(json['prevChapterNumber']),
nextChapterId: json['nextChapterId'] as String?,
nextChapterNumber: json['nextChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
nextChapterNumber: _toNullableInt(json['nextChapterNumber']),
createdAt: _toDateTime(json['createdAt']),
);
@override
@@ -75,12 +96,12 @@ class ChapterListItem extends Equatable {
factory ChapterListItem.fromJson(Map<String, dynamic> json) => ChapterListItem(
id: json['id'] as String,
number: (json['number'] as num).toInt(),
number: _toInt(json['number']),
title: json['title'] as String,
volumeNumber: json['volumeNumber'] as int?,
volumeNumber: _toNullableInt(json['volumeNumber']),
volumeTitle: json['volumeTitle'] as String?,
volumeChapterNumber: json['volumeChapterNumber'] as int?,
createdAt: DateTime.parse(json['createdAt'] as String),
volumeChapterNumber: _toNullableInt(json['volumeChapterNumber']),
createdAt: _toDateTime(json['createdAt']),
);
@override
+25
View File
@@ -7,6 +7,7 @@ class ApiClient {
ApiClient({
required String baseUrl,
required SecureStore secureStore,
this.onSessionExpired,
}) : _secureStore = secureStore,
dio = Dio(
BaseOptions(
@@ -35,6 +36,13 @@ class ApiClient {
handler.next(response);
},
onError: (error, handler) {
final statusCode = error.response?.statusCode;
final path = error.requestOptions.path;
if ((statusCode == 401 || statusCode == 403) && !_isAuthEndpoint(path)) {
_handleSessionExpired();
}
debugPrint(
'[API][ERROR] ${error.requestOptions.method} ${error.requestOptions.baseUrl}${error.requestOptions.path} '
'-> ${error.type}: ${error.message}',
@@ -47,4 +55,21 @@ class ApiClient {
final Dio dio;
final SecureStore _secureStore;
final VoidCallback? onSessionExpired;
DateTime? _lastSessionExpiredAt;
bool _isAuthEndpoint(String path) {
return path.contains('/api/auth/mobile-login');
}
void _handleSessionExpired() {
final now = DateTime.now();
if (_lastSessionExpiredAt != null &&
now.difference(_lastSessionExpiredAt!) < const Duration(seconds: 2)) {
return;
}
_lastSessionExpiredAt = now;
_secureStore.clear();
onSessionExpired?.call();
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/session_expiry_notifier.dart';
import '../config/app_config.dart';
import '../storage/secure_store.dart';
import 'api_client.dart';
@@ -8,5 +9,9 @@ final secureStoreProvider = Provider<SecureStore>((ref) => SecureStore());
final apiClientProvider = Provider<ApiClient>((ref) {
final secureStore = ref.watch(secureStoreProvider);
return ApiClient(baseUrl: AppConfig.baseUrl, secureStore: secureStore);
return ApiClient(
baseUrl: AppConfig.baseUrl,
secureStore: secureStore,
onSessionExpired: () => ref.read(sessionExpiryProvider.notifier).notifyExpired(),
);
});
@@ -144,6 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
await _store.clear();
state = AuthUnauthenticated();
}
Future<void> handleSessionExpired() async {
await _store.clear();
state = AuthUnauthenticated();
}
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
@@ -5,18 +5,47 @@ import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/models/novel_model.dart';
import '../../../core/storage/local_store.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
import '../providers/novels_provider.dart';
class NovelDetailScreen extends ConsumerWidget {
final novelReadProgressProvider =
FutureProvider.family<Map<String, dynamic>?, String>((ref, novelId) async {
final localStore = ref.read(localStoreProvider);
return localStore.loadProgress(novelId);
});
class NovelDetailScreen extends ConsumerStatefulWidget {
const NovelDetailScreen({super.key, required this.novelId});
final String novelId;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<NovelDetailScreen> createState() => _NovelDetailScreenState();
}
class _NovelDetailScreenState extends ConsumerState<NovelDetailScreen> {
int _currentPage = 1;
@override
void didUpdateWidget(covariant NovelDetailScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.novelId != widget.novelId && _currentPage != 1) {
setState(() => _currentPage = 1);
}
}
@override
Widget build(BuildContext context) {
final novelId = widget.novelId;
final novelAsync = ref.watch(novelDetailProvider(novelId));
final chaptersAsync = ref.watch(chapterListProvider(novelId));
final chaptersAsync = ref.watch(
chapterListProvider(ChapterListQuery(novelId: novelId, page: _currentPage)),
);
final firstChapterAsync = ref.watch(
chapterListProvider(ChapterListQuery(novelId: novelId, page: 1)),
);
final readProgressAsync = ref.watch(novelReadProgressProvider(novelId));
final isBookmarked = ref.watch(isBookmarkedProvider(novelId));
return Scaffold(
@@ -56,20 +85,30 @@ class NovelDetailScreen extends ConsumerWidget {
_StatsRow(novel: novel),
const SizedBox(height: 16),
// Read button
chaptersAsync.when(
firstChapterAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, error) => const SizedBox.shrink(),
data: (chapters) {
if (chapters.isEmpty) return const SizedBox.shrink();
final first = chapters.first;
error: (_, _) => const SizedBox.shrink(),
data: (firstPage) {
final first =
firstPage.chapters.isNotEmpty ? firstPage.chapters.first : null;
if (first == null) return const SizedBox.shrink();
final progress = readProgressAsync.valueOrNull;
final continueChapterId = progress?['chapterId'] as String?;
final continueChapterNumber =
(progress?['chapterNumber'] as num?)?.toInt();
final hasProgress = continueChapterId != null && continueChapterId.isNotEmpty;
final targetChapterId = hasProgress ? continueChapterId : first.id;
final buttonLabel = hasProgress
? 'Đọc tiếp chương ${continueChapterNumber ?? '?'}'
: 'Đọc từ đầu';
return FilledButton.icon(
onPressed: () => context.push(
RouteNames.readerChapter(first.id),
RouteNames.readerChapter(targetChapterId),
),
icon: const Icon(Icons.menu_book),
label: Text(
'Đọc Chương ${first.number}: ${first.title}',
),
label: Text(buttonLabel),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
@@ -83,8 +122,8 @@ class NovelDetailScreen extends ConsumerWidget {
Text('Danh sách chương', style: Theme.of(context).textTheme.titleMedium),
const Spacer(),
chaptersAsync.whenOrNull(
data: (chapters) => Text(
'${chapters.length} chương',
data: (pageData) => Text(
'${pageData.totalChapters} chương • Trang ${pageData.currentPage}/${pageData.totalPages == 0 ? 1 : pageData.totalPages}',
style: Theme.of(context).textTheme.bodySmall,
),
) ?? const SizedBox.shrink(),
@@ -102,12 +141,22 @@ class NovelDetailScreen extends ConsumerWidget {
child: Center(child: CircularProgressIndicator()),
),
),
error: (_, error) =>
const SliverToBoxAdapter(child: SizedBox.shrink()),
data: (chapters) => SliverList(
error: (error, _) => SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
'Không tải được danh sách chương: $error',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(color: Theme.of(context).colorScheme.error),
),
),
),
data: (pageData) => SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final ch = chapters[index];
final ch = pageData.chapters[index];
return ListTile(
dense: true,
title: Text(
@@ -118,10 +167,43 @@ class NovelDetailScreen extends ConsumerWidget {
onTap: () => context.push(RouteNames.readerChapter(ch.id)),
);
},
childCount: chapters.length,
childCount: pageData.chapters.length,
),
),
),
SliverToBoxAdapter(
child: chaptersAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (pageData) {
final totalPages = pageData.totalPages == 0 ? 1 : pageData.totalPages;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Row(
children: [
OutlinedButton.icon(
onPressed: _currentPage > 1
? () => setState(() => _currentPage = _currentPage - 1)
: null,
icon: const Icon(Icons.chevron_left),
label: const Text('Trang trước'),
),
const Spacer(),
Text('Trang $_currentPage/$totalPages'),
const Spacer(),
OutlinedButton.icon(
onPressed: _currentPage < totalPages
? () => setState(() => _currentPage = _currentPage + 1)
: null,
icon: const Icon(Icons.chevron_right),
label: const Text('Trang sau'),
),
],
),
);
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
@@ -4,6 +4,8 @@ import '../../../core/models/novel_model.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/network/providers.dart';
const chapterPageSize = 50;
// ─── Browse / Search ──────────────────────────────────────────────────────────
class BrowseParams {
@@ -109,13 +111,77 @@ final novelDetailProvider =
// ─── Chapter List ─────────────────────────────────────────────────────────────
class ChapterListQuery {
const ChapterListQuery({required this.novelId, this.page = 1});
final String novelId;
final int page;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ChapterListQuery &&
other.novelId == novelId &&
other.page == page;
}
@override
int get hashCode => Object.hash(novelId, page);
}
class ChapterListPage {
const ChapterListPage({
required this.chapters,
required this.totalChapters,
required this.totalPages,
required this.currentPage,
});
final List<ChapterListItem> chapters;
final int totalChapters;
final int totalPages;
final int currentPage;
}
final chapterListProvider =
FutureProvider.family<List<ChapterListItem>, String>((ref, novelId) async {
FutureProvider.family<ChapterListPage, ChapterListQuery>((ref, query) async {
final client = ref.read(apiClientProvider);
final res = await client.dio.get('/api/truyen/$novelId/chapters');
final data = res.data as Map<String, dynamic>;
final chapters = data['chapters'] as List? ?? [];
return chapters
.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>))
.toList();
Future<Map<String, dynamic>> fetchChapterPage(String idOrSlug) async {
final res = await client.dio.get(
'/api/truyen/$idOrSlug/chapters',
queryParameters: {
'page': query.page,
'limit': chapterPageSize,
},
);
return res.data as Map<String, dynamic>;
}
var data = await fetchChapterPage(query.novelId);
var chapters = data['chapters'] as List? ?? const [];
// Backend stores chapters by novel id in MongoDB; if route opened by slug,
// first request can return empty list. Resolve canonical id and retry once.
if (chapters.isEmpty) {
try {
final novelRes = await client.dio.get('/api/novels/${query.novelId}');
final novelData = novelRes.data as Map<String, dynamic>;
final canonicalId = novelData['id'] as String?;
if (canonicalId != null && canonicalId.isNotEmpty && canonicalId != query.novelId) {
data = await fetchChapterPage(canonicalId);
chapters = data['chapters'] as List? ?? const [];
}
} catch (_) {
// Keep original empty list when fallback resolution fails.
}
}
return ChapterListPage(
chapters:
chapters.map((e) => ChapterListItem.fromJson(e as Map<String, dynamic>)).toList(),
totalChapters: (data['totalChapters'] as num?)?.toInt() ?? 0,
totalPages: (data['totalPages'] as num?)?.toInt() ?? 0,
currentPage: (data['currentPage'] as num?)?.toInt() ?? query.page,
);
});
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -33,6 +34,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
double _lastScrollOffset = 0;
double _scrollDeltaSinceToggle = 0;
int _chapterDirection = 0; // -1: previous, 1: next
int _lastAutoScrolledParagraph = -1;
int _lastTtsCompletedCount = 0;
String? _autoStartQueuedChapterId;
final List<GlobalKey> _paragraphKeys = [];
List<String> _paragraphsOf(String content) => content
.split(RegExp(r'\n+'))
@@ -61,6 +66,112 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
}
}
Widget _buildParagraphText({
required BuildContext context,
required String paragraph,
required TextStyle style,
required TextAlign textAlign,
required bool isActiveParagraph,
required int highlightStart,
required int highlightEnd,
}) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final safeStart = highlightStart.clamp(0, paragraph.length);
final safeEnd = highlightEnd.clamp(0, paragraph.length);
if (safeEnd <= safeStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final highlightStyle = style.copyWith(
backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(80),
fontWeight: FontWeight.w600,
);
return RichText(
textAlign: textAlign,
text: TextSpan(
style: style,
children: [
if (safeStart > 0) TextSpan(text: paragraph.substring(0, safeStart)),
TextSpan(text: paragraph.substring(safeStart, safeEnd), style: highlightStyle),
if (safeEnd < paragraph.length) TextSpan(text: paragraph.substring(safeEnd)),
],
),
);
}
void _ensureParagraphKeys(int count) {
if (_paragraphKeys.length == count) return;
_paragraphKeys
..clear()
..addAll(List.generate(count, (_) => GlobalKey()));
_lastAutoScrolledParagraph = -1;
}
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
if (tts.status == TtsStatus.idle) return;
final index = tts.activeParagraphIndex;
if (index < 0 || index >= paragraphCount) return;
if (index == _lastAutoScrolledParagraph) return;
_lastAutoScrolledParagraph = index;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _paragraphKeys[index].currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(
ctx,
alignment: 0.22,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
});
}
int _firstVisibleParagraphIndex() {
if (!_scrollCtrl.hasClients || _paragraphKeys.isEmpty) return 0;
final viewportTop = _scrollCtrl.offset + 8;
final viewportBottom =
_scrollCtrl.offset + _scrollCtrl.position.viewportDimension - 8;
int? partiallyVisibleIndex;
for (var i = 0; i < _paragraphKeys.length; i++) {
final ctx = _paragraphKeys[i].currentContext;
if (ctx == null) continue;
final renderObject = ctx.findRenderObject();
if (renderObject == null || !renderObject.attached) continue;
final viewport = RenderAbstractViewport.of(renderObject);
final top = viewport.getOffsetToReveal(renderObject, 0).offset;
final bottom = viewport.getOffsetToReveal(renderObject, 1).offset;
final fullyVisible = top >= viewportTop && bottom <= viewportBottom;
if (fullyVisible) return i;
final partiallyVisible = bottom > viewportTop && top < viewportBottom;
if (partiallyVisible && partiallyVisibleIndex == null) {
partiallyVisibleIndex = i;
}
}
return partiallyVisibleIndex ?? 0;
}
@override
void initState() {
super.initState();
@@ -191,7 +302,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
builder: (sheetContext) {
return Consumer(
builder: (context, ref, _) {
final chaptersAsync = ref.watch(chapterListProvider(currentChapter.novelId));
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
final chaptersAsync = ref.watch(
chapterListProvider(
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
),
);
return FractionallySizedBox(
heightFactor: 0.82,
child: SafeArea(
@@ -220,13 +336,14 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: chaptersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Không tải được mục lục: $e')),
data: (chapters) {
data: (pageData) {
final chapters = pageData.chapters;
if (chapters.isEmpty) {
return const Center(child: Text('Chưa có danh sách chương.'));
}
return ListView.separated(
itemCount: chapters.length,
separatorBuilder: (_, __) => const Divider(height: 1),
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = chapters[index];
final isCurrent = item.id == currentChapter.id;
@@ -264,7 +381,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
);
}
Future<void> _openReadingSettingsSheet(String previewContent) async {
Future<void> _openReadingSettingsSheet(
String previewContent,
String chapterId,
String chapterTitle,
) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@@ -277,6 +398,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final tts = ref.watch(ttsProvider);
final ttsNotifier = ref.read(ttsProvider.notifier);
void closeSettingsSheet() {
if (!sheetContext.mounted) return;
final route = ModalRoute.of(sheetContext);
if (route != null) {
Navigator.of(sheetContext).removeRoute(route);
return;
}
Navigator.of(sheetContext).maybePop();
}
Future<void> update(dynamic next) async {
await ref.read(readingSettingsProvider.notifier).update(next);
}
@@ -314,7 +445,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
child: const Text('Mặc định'),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: closeSettingsSheet,
icon: const Icon(Icons.close),
),
],
@@ -544,7 +675,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text('${tts.speed}x', style: Theme.of(context).textTheme.labelLarge),
child: Text(
formatTtsSpeedLabel(tts.speed),
style: Theme.of(context).textTheme.labelLarge,
),
),
],
),
@@ -552,17 +686,83 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Wrap(
spacing: 8,
runSpacing: 8,
children: [0.75, 1.0, 1.25, 1.5, 1.75].map((speed) {
children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
final selected = tts.speed == speed;
return ChoiceChip(
label: Text('${speed}x'),
label: Text(formatTtsSpeedLabel(speed)),
selected: selected,
onSelected: (_) => ttsNotifier.setSpeed(speed),
);
}).toList(),
),
const SizedBox(height: 12),
TtsPlayerWidget(content: previewContent),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Chạy nền cho TTS'),
subtitle: const Text(
'Tiếp tục đọc khi chuyển app hoặc tắt màn hình (Android)',
),
value: tts.backgroundModeEnabled,
onChanged: ttsNotifier.setBackgroundModeEnabled,
),
if (tts.backgroundModeEnabled)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Loại trừ tối ưu pin'),
subtitle: Text(
tts.batteryOptimizationIgnored
? 'Đã bật: Android sẽ ít khả năng dừng TTS khi chạy nền.'
: 'Nên bật để Android không chặn TTS khi tắt màn hình.',
),
trailing: tts.batteryOptimizationIgnored
? const Icon(Icons.verified, color: Colors.green)
: OutlinedButton(
onPressed: ttsNotifier
.ensureBatteryOptimizationIgnored,
child: const Text('Bật ngay'),
),
),
const SizedBox(height: 12),
if (tts.availableVietnameseVoices.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: tts.voiceName,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Giọng đọc tiếng Việt',
border: OutlineInputBorder(),
),
items: tts.availableVietnameseVoices
.map(
(v) => DropdownMenuItem<String>(
value: v.name,
child: Text(
v.displayName,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: (value) {
if (value != null) {
ttsNotifier.setVoiceByName(value);
}
},
)
else
Text(
'Thiết bị không cung cấp nhiều giọng tiếng Việt. Đang dùng ${tts.voiceName ?? tts.language}.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
TtsPlayerWidget(
content: previewContent,
contentKey: chapterId,
title: 'Chương $chapterTitle',
includeTitleOnStart: false,
resolveStartParagraphIndex:
_firstVisibleParagraphIndex,
onStarted: closeSettingsSheet,
),
],
),
),
@@ -628,8 +828,42 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
data: (chapter) {
final paragraphs = _paragraphsOf(chapter.content);
_ensureParagraphKeys(paragraphs.length);
final textAlign = _textAlignFor(settings.textAlign);
final novelAsync = ref.watch(novelDetailProvider(chapter.novelId));
final tts = ref.watch(ttsProvider);
final shouldHighlightTts = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (tts.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = tts.completedCount;
if (tts.contentKey == chapter.id && chapter.nextChapterId != null) {
final nextChapterId = chapter.nextChapterId!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(nextChapterId);
context.pushReplacement(RouteNames.readerChapter(nextChapterId));
});
}
}
if (tts.pendingAutoStartChapterId == chapter.id &&
_autoStartQueuedChapterId != chapter.id) {
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final notifier = ref.read(ttsProvider.notifier);
notifier.clearPendingAutoStartChapter();
notifier.startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
);
_autoStartQueuedChapterId = null;
});
}
_maybeAutoScrollToTtsParagraph(tts, paragraphs.length);
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeChapterSession(chapter);
});
@@ -645,7 +879,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
_TopBar(
title: _chapterTopBarTitle(chapter),
progress: _readingProgress,
onOpenSettings: () => _openReadingSettingsSheet(chapter.content),
onOpenSettings: () => _openReadingSettingsSheet(
chapter.content,
chapter.id,
'Chương ${chapter.number}: ${chapter.title}',
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
),
@@ -693,7 +931,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
color: readerMutedColor,
),
),
error: (_, __) => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (novel) => Text(
novel.title,
maxLines: 1,
@@ -727,13 +965,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
children: [
for (var index = 0; index < paragraphs.length; index++)
Padding(
key: _paragraphKeys[index],
padding: EdgeInsets.only(
bottom: index == paragraphs.length - 1
? 0
: settings.paragraphSpacing,
),
child: SelectableText(
paragraphs[index],
child: _buildParagraphText(
context: context,
paragraph: paragraphs[index],
textAlign: textAlign,
style: TextStyle(
color: readerTextColor,
@@ -746,6 +986,10 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
? 'Courier'
: null,
),
isActiveParagraph: shouldHighlightTts &&
tts.activeParagraphIndex == index,
highlightStart: tts.progressStart,
highlightEnd: tts.progressEnd,
),
),
],
@@ -801,6 +1045,27 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
),
)
: null,
bottomNavigationBar: chapterAsync.whenOrNull(
data: (chapter) {
final tts = ref.watch(ttsProvider);
final showMini = tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (!showMini) return const SizedBox.shrink();
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 10),
child: TtsPlayerWidget(
compact: true,
content: chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
),
),
);
},
),
);
}
}
@@ -1,44 +1,206 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../tts/tts_service.dart';
class TtsPlayerWidget extends ConsumerWidget {
const TtsPlayerWidget({
super.key,
required this.content,
this.contentKey,
this.title,
this.includeTitleOnStart = true,
this.resolveStartParagraphIndex,
this.onStarted,
this.compact = false,
});
final String content;
const TtsPlayerWidget({super.key, required this.content});
final String? contentKey;
final String? title;
final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted;
final bool compact;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tts = ref.watch(ttsProvider);
final notifier = ref.read(ttsProvider.notifier);
const speeds = [0.35, 0.45, 0.55, 0.65, 0.8, 1.0];
Future<void> start() async {
if (tts.status == TtsStatus.paused) {
unawaited(notifier.resume());
onStarted?.call();
return;
}
unawaited(
notifier.startReading(
content,
paragraphIndex: tts.paragraphIndex,
startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey,
title: title,
includeTitle: includeTitleOnStart,
),
);
onStarted?.call();
}
Widget speedButton() {
return PopupMenuButton<double>(
initialValue: tts.speed,
onSelected: notifier.setSpeed,
icon: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Theme.of(context).colorScheme.surface.withAlpha(170),
),
child: Text(
formatTtsSpeedLabel(tts.speed),
style: Theme.of(context).textTheme.labelLarge,
),
),
itemBuilder: (_) => speeds
.map((s) => PopupMenuItem(value: s, child: Text(formatTtsSpeedLabel(s))))
.toList(),
);
}
if (compact) {
final progressValue = tts.totalParagraphs > 0
? ((tts.paragraphIndex + 1) / tts.totalParagraphs).clamp(0.0, 1.0)
: 0.0;
return SizedBox(
height: 82,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 6),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primaryContainer,
Theme.of(context).colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(28),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withAlpha(180),
borderRadius: BorderRadius.circular(999),
),
child: Icon(
Icons.graphic_eq_rounded,
size: 20,
color: Theme.of(context).colorScheme.onPrimary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title?.trim().isNotEmpty == true ? title! : 'Đang phát TTS',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
tts.totalParagraphs > 0
? 'Câu ${tts.paragraphIndex + 1}/${tts.totalParagraphs}'
: (tts.voiceName ?? tts.language),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
if (!tts.isPlaying)
IconButton.filled(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.play_arrow_rounded),
onPressed: () => start(),
)
else
IconButton.filled(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.pause_rounded),
onPressed: notifier.pause,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.stop_rounded),
onPressed: tts.status != TtsStatus.idle ? notifier.stop : null,
),
speedButton(),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 4,
value: progressValue,
backgroundColor: Theme.of(context).colorScheme.surface.withAlpha(120),
),
),
],
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(20),
),
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
),
// Play/Pause/Stop
if (!tts.isPlaying)
IconButton.filled(
icon: const Icon(Icons.play_arrow),
onPressed: () {
if (tts.status == TtsStatus.paused) {
notifier.resume();
return;
}
notifier.startReading(
content,
paragraphIndex: tts.paragraphIndex,
);
},
onPressed: () => start(),
)
else
IconButton.filled(
@@ -53,43 +215,25 @@ class TtsPlayerWidget extends ConsumerWidget {
icon: const Icon(Icons.skip_next),
onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null,
),
// Speed control
PopupMenuButton<double>(
initialValue: tts.speed,
onSelected: notifier.setSpeed,
icon: Text(
'${tts.speed}x',
style: Theme.of(context).textTheme.labelSmall,
speedButton(),
],
),
itemBuilder: (_) => [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
.map((s) => PopupMenuItem(value: s, child: Text('${s}x')))
.toList(),
),
// Progress indicator
const SizedBox(height: 6),
Wrap(
spacing: 12,
runSpacing: 4,
children: [
if (tts.totalParagraphs > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
Text(
'${tts.paragraphIndex + 1}/${tts.totalParagraphs}',
style: Theme.of(context).textTheme.labelSmall,
),
),
if (tts.voiceName != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tts.voiceName!,
Text(
tts.voiceName ?? tts.language,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
)
else
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tts.language,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
],
),
+280 -19
View File
@@ -1,43 +1,123 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.45;
double ttsDisplayMultiplier(double speechRate) => speechRate / kTtsBaseSpeechRate;
String formatTtsSpeedLabel(double speechRate) {
final multiplier = ttsDisplayMultiplier(speechRate);
final rounded = multiplier.roundToDouble();
if ((multiplier - rounded).abs() < 0.05) {
return '${rounded.toInt()}x';
}
return '${multiplier.toStringAsFixed(1)}x';
}
class _TtsSegment {
const _TtsSegment({
required this.text,
required this.paragraphIndex,
required this.start,
required this.end,
});
final String text;
final int paragraphIndex;
final int start;
final int end;
}
class TtsVoice {
const TtsVoice({required this.name, required this.locale});
final String name;
final String locale;
String get displayName => '$name ($locale)';
}
class TtsState {
final TtsStatus status;
final int paragraphIndex;
final int totalParagraphs;
final int paragraphIndex; // current spoken segment index
final int totalParagraphs; // total spoken segments
final int activeParagraphIndex; // paragraph index in chapter content
final double speed;
final String language;
final String? voiceName;
final List<TtsVoice> availableVietnameseVoices;
final int progressStart;
final int progressEnd;
final String? contentKey;
final int completedCount;
final bool backgroundModeEnabled;
final bool batteryOptimizationIgnored;
final String? pendingAutoStartChapterId;
const TtsState({
this.status = TtsStatus.idle,
this.paragraphIndex = 0,
this.totalParagraphs = 0,
this.speed = 1.0,
this.activeParagraphIndex = -1,
this.speed = 0.45,
this.language = 'vi-VN',
this.voiceName,
this.availableVietnameseVoices = const [],
this.progressStart = -1,
this.progressEnd = -1,
this.contentKey,
this.completedCount = 0,
this.backgroundModeEnabled = true,
this.batteryOptimizationIgnored = false,
this.pendingAutoStartChapterId,
});
TtsState copyWith({
TtsStatus? status,
int? paragraphIndex,
int? totalParagraphs,
int? activeParagraphIndex,
double? speed,
String? language,
String? voiceName,
List<TtsVoice>? availableVietnameseVoices,
int? progressStart,
int? progressEnd,
String? contentKey,
bool clearContentKey = false,
bool clearVoiceName = false,
int? completedCount,
bool? backgroundModeEnabled,
bool? batteryOptimizationIgnored,
String? pendingAutoStartChapterId,
bool clearPendingAutoStartChapterId = false,
}) =>
TtsState(
status: status ?? this.status,
paragraphIndex: paragraphIndex ?? this.paragraphIndex,
totalParagraphs: totalParagraphs ?? this.totalParagraphs,
activeParagraphIndex: activeParagraphIndex ?? this.activeParagraphIndex,
speed: speed ?? this.speed,
language: language ?? this.language,
voiceName: clearVoiceName ? null : (voiceName ?? this.voiceName),
availableVietnameseVoices:
availableVietnameseVoices ?? this.availableVietnameseVoices,
progressStart: progressStart ?? this.progressStart,
progressEnd: progressEnd ?? this.progressEnd,
contentKey: clearContentKey ? null : (contentKey ?? this.contentKey),
completedCount: completedCount ?? this.completedCount,
backgroundModeEnabled: backgroundModeEnabled ?? this.backgroundModeEnabled,
batteryOptimizationIgnored:
batteryOptimizationIgnored ?? this.batteryOptimizationIgnored,
pendingAutoStartChapterId: clearPendingAutoStartChapterId
? null
: (pendingAutoStartChapterId ?? this.pendingAutoStartChapterId),
);
bool get isPlaying => status == TtsStatus.playing;
@@ -45,7 +125,8 @@ class TtsState {
class TtsNotifier extends StateNotifier<TtsState> {
final FlutterTts _tts = FlutterTts();
List<String> _paragraphs = [];
static const MethodChannel _backgroundChannel = MethodChannel('reader_app/tts_background');
List<_TtsSegment> _segments = [];
bool _initialized = false;
Future<void>? _initFuture;
@@ -74,12 +155,15 @@ class TtsNotifier extends StateNotifier<TtsState> {
}
await _configureVietnameseVoice();
await _tts.setSpeechRate(1.0);
await _tts.setSpeechRate(kTtsBaseSpeechRate);
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_tts.setStartHandler(() {
state = state.copyWith(status: TtsStatus.playing);
state = state.copyWith(
status: TtsStatus.playing,
);
unawaited(_syncBackgroundMode());
});
_tts.setCompletionHandler(() {
@@ -90,8 +174,11 @@ class TtsNotifier extends StateNotifier<TtsState> {
_tts.setErrorHandler((msg) {
state = state.copyWith(status: TtsStatus.idle);
unawaited(_syncBackgroundMode());
});
await _syncBackgroundMode();
_initialized = true;
}
@@ -100,6 +187,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
String? selectedName;
String selectedLanguage = 'vi-VN';
final List<TtsVoice> vietnameseVoices = [];
if (voicesRaw is List) {
final vietnamese = voicesRaw.whereType<Map>().where((voice) {
@@ -107,6 +195,13 @@ class TtsNotifier extends StateNotifier<TtsState> {
return locale.startsWith('vi');
}).toList();
for (final voice in vietnamese) {
final name = voice['name']?.toString();
final locale = (voice['locale'] ?? voice['language'])?.toString();
if (name == null || name.isEmpty || locale == null || locale.isEmpty) continue;
vietnameseVoices.add(TtsVoice(name: name, locale: locale));
}
if (vietnamese.isNotEmpty) {
final preferred = vietnamese.firstWhere(
(voice) =>
@@ -124,66 +219,226 @@ class TtsNotifier extends StateNotifier<TtsState> {
if (selectedName != null) {
await _tts.setVoice({'name': selectedName, 'locale': selectedLanguage});
}
state = state.copyWith(language: selectedLanguage, voiceName: selectedName);
state = state.copyWith(
language: selectedLanguage,
voiceName: selectedName,
availableVietnameseVoices: vietnameseVoices,
);
}
Future<void> setVoiceByName(String voiceName) async {
final selected = state.availableVietnameseVoices.where((v) => v.name == voiceName);
if (selected.isEmpty) return;
final voice = selected.first;
if (!voice.locale.toLowerCase().startsWith('vi')) return;
await _tts.setLanguage(voice.locale);
await _tts.setVoice({'name': voice.name, 'locale': voice.locale});
state = state.copyWith(language: voice.locale, voiceName: voice.name);
}
Future<void> setBackgroundModeEnabled(bool enabled) async {
state = state.copyWith(backgroundModeEnabled: enabled);
await _syncBackgroundMode();
if (enabled) {
await ensureBatteryOptimizationIgnored();
}
}
void scheduleAutoStartForChapter(String chapterId) {
state = state.copyWith(pendingAutoStartChapterId: chapterId);
}
void clearPendingAutoStartChapter() {
state = state.copyWith(clearPendingAutoStartChapterId: true);
}
Future<void> ensureBatteryOptimizationIgnored() async {
if (!Platform.isAndroid) return;
try {
final isIgnored = await _backgroundChannel.invokeMethod<bool>(
'isIgnoringBatteryOptimizations',
) ??
false;
state = state.copyWith(batteryOptimizationIgnored: isIgnored);
if (isIgnored) return;
await _backgroundChannel.invokeMethod<void>('requestIgnoreBatteryOptimizations');
final afterRequest = await _backgroundChannel.invokeMethod<bool>(
'isIgnoringBatteryOptimizations',
) ??
false;
state = state.copyWith(batteryOptimizationIgnored: afterRequest);
} catch (_) {
// Ignore bridge errors and keep TTS playback functional.
}
}
Future<void> _syncBackgroundMode() async {
if (!Platform.isAndroid) return;
final shouldKeepAlive =
state.backgroundModeEnabled && state.status == TtsStatus.playing;
try {
await _backgroundChannel
.invokeMethod<void>('setWakeLock', {'enabled': shouldKeepAlive});
} catch (_) {
// Keep playback functional even if native wake lock bridge is unavailable.
}
}
/// Start reading from [content] starting at optional [paragraphIndex].
Future<void> startReading(String content, {int paragraphIndex = 0}) async {
Future<void> startReading(
String content, {
int paragraphIndex = 0,
int? startParagraphIndex,
String? contentKey,
String? title,
bool includeTitle = true,
}) async {
if (!_initialized) {
await (_initFuture ?? _init());
}
_paragraphs = content
final segments = <_TtsSegment>[];
final titleText = title?.trim();
if (includeTitle && titleText != null && titleText.isNotEmpty) {
segments.add(_TtsSegment(text: titleText, paragraphIndex: -1, start: -1, end: -1));
}
final paragraphs = content
.split(RegExp(r'\n+'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
if (_paragraphs.isEmpty) return;
for (var pIndex = 0; pIndex < paragraphs.length; pIndex++) {
final paragraph = paragraphs[pIndex];
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph);
var cursor = 0;
final validIndex = paragraphIndex.clamp(0, _paragraphs.length - 1);
for (final match in sentenceMatches) {
final sentence = match.group(0)?.trim() ?? '';
if (sentence.isEmpty) continue;
var start = paragraph.indexOf(sentence, cursor);
if (start < 0) start = cursor.clamp(0, paragraph.length);
final end = (start + sentence.length).clamp(0, paragraph.length);
cursor = end;
segments.add(
_TtsSegment(
text: sentence,
paragraphIndex: pIndex,
start: start,
end: end,
),
);
}
}
_segments = segments;
if (_segments.isEmpty) return;
var validIndex = paragraphIndex.clamp(0, _segments.length - 1);
if (startParagraphIndex != null) {
final startFromVisible = _segments.indexWhere(
(segment) => segment.paragraphIndex >= startParagraphIndex,
);
if (startFromVisible >= 0) {
validIndex = startFromVisible;
}
}
final selectedSegment = _segments[validIndex];
state = state.copyWith(
status: TtsStatus.playing,
paragraphIndex: validIndex,
totalParagraphs: _paragraphs.length,
totalParagraphs: _segments.length,
activeParagraphIndex: selectedSegment.paragraphIndex,
progressStart: selectedSegment.start,
progressEnd: selectedSegment.end,
contentKey: contentKey,
);
await _syncBackgroundMode();
await _speak(validIndex);
}
Future<void> _speak(int index) async {
if (index >= _paragraphs.length) {
state = state.copyWith(status: TtsStatus.idle);
if (index >= _segments.length) {
state = state.copyWith(
status: TtsStatus.idle,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
);
await _syncBackgroundMode();
return;
}
final segment = _segments[index];
state = state.copyWith(
paragraphIndex: index,
activeParagraphIndex: segment.paragraphIndex,
progressStart: segment.start,
progressEnd: segment.end,
);
await _tts.setSpeechRate(state.speed);
await _tts.speak(_paragraphs[index]);
await _tts.speak(segment.text);
}
Future<void> _next() async {
final next = state.paragraphIndex + 1;
if (next >= state.totalParagraphs) {
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
completedCount: state.completedCount + 1,
);
await _syncBackgroundMode();
return;
}
state = state.copyWith(paragraphIndex: next);
state = state.copyWith(
paragraphIndex: next,
activeParagraphIndex: _segments[next].paragraphIndex,
progressStart: _segments[next].start,
progressEnd: _segments[next].end,
);
await _speak(next);
}
Future<void> pause() async {
await _tts.pause();
state = state.copyWith(status: TtsStatus.paused);
await _syncBackgroundMode();
}
Future<void> resume() async {
if (state.status != TtsStatus.paused) return;
state = state.copyWith(status: TtsStatus.playing);
await _syncBackgroundMode();
// Use paragraph-level resume for consistent behavior across engines.
await _speak(state.paragraphIndex);
}
Future<void> stop() async {
await _tts.stop();
state = state.copyWith(status: TtsStatus.idle, paragraphIndex: 0);
state = state.copyWith(
status: TtsStatus.idle,
paragraphIndex: 0,
activeParagraphIndex: -1,
progressStart: -1,
progressEnd: -1,
clearContentKey: true,
);
await _syncBackgroundMode();
}
Future<void> skipForward() async {
@@ -195,7 +450,12 @@ class TtsNotifier extends StateNotifier<TtsState> {
await _tts.stop();
if (state.totalParagraphs <= 0) return;
final prev = (state.paragraphIndex - 1).clamp(0, state.totalParagraphs - 1);
state = state.copyWith(paragraphIndex: prev);
state = state.copyWith(
paragraphIndex: prev,
activeParagraphIndex: _segments[prev].paragraphIndex,
progressStart: _segments[prev].start,
progressEnd: _segments[prev].end,
);
if (state.status == TtsStatus.playing) await _speak(prev);
}
@@ -206,6 +466,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
@override
void dispose() {
unawaited(_backgroundChannel.invokeMethod<void>('setWakeLock', {'enabled': false}));
_tts.stop();
super.dispose();
}
+2 -2
View File
@@ -8,6 +8,8 @@ import 'app/app.dart';
import 'core/logging/app_provider_observer.dart';
void main() {
runZonedGuarded(
() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
@@ -22,8 +24,6 @@ void main() {
return true;
};
runZonedGuarded(
() {
runApp(
const ProviderScope(
observers: [AppProviderObserver()],