feat: Implement TTS playback store and enhance reading progress synchronization
Build Android APK / build-apk (push) Has been cancelled
Build Android AAB / build-aab (push) Has been cancelled

- Added ReaderTtsPlaybackStore to manage TTS start requests with a maximum of 4 pending requests.
- Updated app configuration to use a production API URL.
- Enhanced BookmarkModel to infer type when not provided by the API for backward compatibility.
- Introduced methods in LocalStore for saving, loading, and clearing the last route path.
- Implemented syncProgress method in BookshelfNotifier to update reading progress and bookmarks from the server.
- Modified ReaderScreen to handle chapter navigation and TTS playback more effectively, including auto-start logic.
- Updated TtsPlayerWidget to accept additional parameters for chapter navigation.
- Enhanced TtsNotifier to handle new parameters for TTS requests and manage playback state.
- Improved SplashScreen to restore the last visited route after splash screen display.
This commit is contained in:
2026-04-27 00:48:05 +07:00
parent 66613857e8
commit c3e6d66f43
14 changed files with 758 additions and 128 deletions
+18
View File
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../core/auth/session_expiry_notifier.dart';
import '../core/theme/app_theme.dart';
import '../core/storage/local_store.dart';
import '../features/auth/providers/auth_provider.dart';
import '../features/reader/tts/tts_service.dart';
import 'router/route_names.dart';
@@ -19,10 +23,23 @@ class ReaderApp extends ConsumerStatefulWidget {
class _ReaderAppState extends ConsumerState<ReaderApp> {
final _scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
ProviderSubscription<int>? _sessionExpirySub;
late final GoRouter _router;
void _persistRouteForRestore() {
if (!mounted) return;
unawaited(() async {
final uri = _router.state.uri;
final fullPath = uri.hasQuery ? '${uri.path}?${uri.query}' : uri.path;
if (fullPath == RouteNames.splash) return;
await ref.read(localStoreProvider).saveLastRoutePath(fullPath);
}());
}
@override
void initState() {
super.initState();
_router = ref.read(appRouterProvider);
_router.routerDelegate.addListener(_persistRouteForRestore);
WidgetsBinding.instance.addPostFrameCallback((_) {
_ensureMandatoryTtsRequirements();
});
@@ -92,6 +109,7 @@ class _ReaderAppState extends ConsumerState<ReaderApp> {
@override
void dispose() {
_router.routerDelegate.removeListener(_persistRouteForRestore);
_sessionExpirySub?.close();
super.dispose();
}
+1 -1
View File
@@ -11,7 +11,7 @@ class AppConfig {
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return 'http://10.0.2.2:8000';
return 'https://reader-api.fevirtus.dev';
}
return 'http://localhost:8000';
+16 -1
View File
@@ -39,13 +39,28 @@ class BookmarkModel extends Equatable {
factory BookmarkModel.fromJson(Map<String, dynamic> json) => BookmarkModel(
id: json['id'] as String,
novelId: json['novelId'] as String,
type: BookmarkType.fromString(json['type'] as String?),
lastChapterId: json['lastChapterId'] as String?,
lastChapterNumber: json['lastChapterNumber'] as int?,
readChapters: (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
type: () {
final explicitType = BookmarkType.fromString(json['type'] as String?);
if ((json['type'] as String?) != null) {
return explicitType;
}
// Backward-compatible inference when API does not return `type`.
final inferredLastChapter = json['lastChapterNumber'] as int?;
final inferredReadChapters = (json['readChapters'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
const <int>[];
return (inferredLastChapter != null || inferredReadChapters.isNotEmpty)
? BookmarkType.reading
: BookmarkType.bookmarked;
}(),
novel: json['novel'] != null
? NovelModel.fromJson(json['novel'] as Map<String, dynamic>)
: null,
+22
View File
@@ -16,6 +16,7 @@ class LocalStore {
static const _kProgressChapterId = 'progress_chapter_id_';
static const _kProgressChapterNum = 'progress_chapter_num_';
static const _kProgressOffset = 'progress_offset_';
static const _kLastRoutePath = 'last_route_path';
// ── Reading settings ──────────────────────────────────────────────────────
@@ -86,6 +87,27 @@ class LocalStore {
'scrollOffset': prefs.getDouble('$_kProgressOffset$novelId') ?? 0.0,
};
}
// ── Last route restore (cold start after process reclaim) ───────────────
Future<void> saveLastRoutePath(String path) async {
final normalized = path.trim();
if (normalized.isEmpty || normalized == '/') return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastRoutePath, normalized);
}
Future<String?> loadLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_kLastRoutePath)?.trim();
if (value == null || value.isEmpty) return null;
return value;
}
Future<void> clearLastRoutePath() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLastRoutePath);
}
}
final localStoreProvider = Provider<LocalStore>((_) => LocalStore());
@@ -23,6 +23,62 @@ class BookshelfNotifier extends StateNotifier<AsyncValue<List<BookmarkModel>>> {
}
}
void syncProgress({
required String novelId,
required String chapterId,
required int chapterNumber,
Map<String, dynamic>? serverBookmark,
}) {
final current = state.valueOrNull ?? const <BookmarkModel>[];
BookmarkModel? parsedFromServer;
if (serverBookmark != null) {
try {
parsedFromServer = BookmarkModel.fromJson(serverBookmark);
} catch (_) {
parsedFromServer = null;
}
}
final index = current.indexWhere((b) => b.novelId == novelId);
if (index >= 0) {
final existing = current[index];
final merged = parsedFromServer ?? BookmarkModel(
id: existing.id,
novelId: existing.novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: {
...existing.readChapters,
chapterNumber,
}.toList()
..sort(),
novel: existing.novel,
);
final updated = [...current]..[index] = merged;
state = AsyncValue.data(updated);
return;
}
if (parsedFromServer != null) {
state = AsyncValue.data([parsedFromServer, ...current]);
return;
}
// Fallback when API response doesn't include bookmark object.
final synthetic = BookmarkModel(
id: 'progress-$novelId',
novelId: novelId,
type: BookmarkType.reading,
lastChapterId: chapterId,
lastChapterNumber: chapterNumber,
readChapters: [chapterNumber],
);
state = AsyncValue.data([synthetic, ...current]);
}
Future<void> toggle(String novelId) async {
try {
final client = _ref.read(apiClientProvider);
@@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/config/app_config.dart';
import '../../../core/models/chapter_model.dart';
import '../../../core/models/reading_settings.dart';
import '../../../core/storage/local_store.dart';
@@ -97,38 +98,64 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required int highlightStart,
required int highlightEnd,
required Function(int charOffset) onSentenceTap,
// When true, renders Text.rich instead of SelectableText.rich.
// This avoids the "selection.isValid" assertion that fires when a
// TapGestureRecognizer on a span triggers TTS/scroll while SelectableText
// still holds a stale internal selection.
bool useTapRecognizer = false,
}) {
if (sentenceSlices.isEmpty) {
return SelectableText(
'',
textAlign: textAlign,
style: style,
onTap: () => onSentenceTap(0),
final spans = sentenceSlices.map((slice) {
final start = slice.start;
final end = slice.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
if (!useTapRecognizer) {
return TextSpan(
text: slice.text,
style: isCurrentSpoken ? highlightStyle : null,
);
}
// Use WidgetSpan + GestureDetector to avoid lifecycle issues from
// creating/discarding many TapGestureRecognizer instances across rebuilds.
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
// Unfocus immediately so SelectableText drops its selection state
// before any scroll notification can call getBoxesForSelection.
FocusManager.instance.primaryFocus?.unfocus();
onSentenceTap(start);
},
child: Text(
slice.text,
style: isCurrentSpoken ? highlightStyle : style,
),
),
);
}).toList();
final textSpan = TextSpan(style: style, children: spans);
if (useTapRecognizer || sentenceSlices.isEmpty) {
// Use plain Text.rich when sentence-tap TTS is active.
// SelectableText keeps an internal TextEditingController/selection that
// becomes invalid after a programmatic rebuild, causing a Flutter
// assertion failure in getBoxesForSelection during scroll.
return GestureDetector(
onTap: sentenceSlices.isEmpty ? () => onSentenceTap(0) : null,
child: RichText(text: textSpan, textAlign: textAlign),
);
}
return SelectableText.rich(
TextSpan(
style: style,
children: sentenceSlices.map((slice) {
final start = slice.start;
final end = slice.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
return TextSpan(
text: slice.text,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
),
textAlign: textAlign,
);
return SelectableText.rich(textSpan, textAlign: textAlign);
}
List<List<_SentenceSlice>> _sentenceSlicesForChapter(
@@ -250,7 +277,29 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
final chapter = chapterAsync.valueOrNull;
if (chapter == null) return;
if (previous.contentKey == chapter.id &&
next.contentKey != null &&
next.contentKey != chapter.id &&
next.contentKey != previous.contentKey &&
(next.status == TtsStatus.playing || next.status == TtsStatus.paused)) {
final targetChapterId = next.contentKey!;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.pushReplacement(RouteNames.readerChapter(targetChapterId));
});
return;
}
// Chapter-completion → auto-advance to next chapter.
// On Android, native service already fetches and starts next chapter.
// Re-queueing auto-start from UI causes duplicate START_READING races.
if (defaultTargetPlatform == TargetPlatform.android) {
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
}
return;
}
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
if (next.contentKey == chapter.id && chapter.nextChapterId != null) {
@@ -276,6 +325,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
);
_autoStartQueuedChapterId = null;
});
@@ -415,9 +467,11 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String targetChapterId,
) {
final tts = ref.read(ttsProvider);
final isCurrentlyReading = tts.contentKey == currentChapterId &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (!isCurrentlyReading) return;
// Only auto-start on the target chapter when TTS is actively PLAYING.
// If paused, the user intentionally stopped do not resume on navigation.
final isActivelyPlaying = tts.contentKey == currentChapterId &&
tts.status == TtsStatus.playing;
if (!isActivelyPlaying) return;
ref.read(ttsProvider.notifier).scheduleAutoStartForChapter(targetChapterId);
}
@@ -426,6 +480,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (tts.pendingAutoStartChapterId != chapter.id) return;
if (_autoStartQueuedChapterId == chapter.id) return;
// If native TTS service already moved to this chapter and is actively
// controlling playback, do not issue another manual START_READING.
final isAlreadyPlayingThisChapter =
tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing || tts.status == TtsStatus.paused);
if (isAlreadyPlayingThisChapter) {
ref.read(ttsProvider.notifier).clearPendingAutoStartChapter();
return;
}
_autoStartQueuedChapterId = chapter.id;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -435,6 +499,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
);
_autoStartQueuedChapterId = null;
});
@@ -549,6 +616,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
String previewContent,
String chapterId,
String chapterTitle,
String? nextChapterId,
int? chapterNumber,
) async {
await showModalBottomSheet<void>(
context: context,
@@ -1019,6 +1088,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: previewContent,
contentKey: chapterId,
title: 'Chương $chapterTitle',
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: AppConfig.baseUrl,
includeTitleOnStart: false,
resolveStartParagraphIndex:
_firstVisibleParagraphIndex,
@@ -1117,6 +1189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
chapter.content,
chapter.id,
'Chương ${chapter.number}: ${chapter.title}',
chapter.nextChapterId,
chapter.number,
),
barBackgroundColor: readerBackground,
foregroundColor: readerTextColor,
@@ -1255,14 +1329,27 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
if (!canStartFromSentence) {
return;
}
// Synchronous unfocus clears stale SelectableText selection
// before startReading triggers a widget rebuild + scroll.
FocusManager.instance.primaryFocus?.unfocus();
ref
.read(ttsProvider.notifier)
.clearPendingAutoStartChapter();
ref.read(ttsProvider.notifier).startReading(
chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
startParagraphIndex: index,
startCharOffset: charOffset,
);
},
useTapRecognizer: settings.enableSentenceTapTts ||
(tts.contentKey == chapter.id &&
(tts.status == TtsStatus.playing ||
tts.status == TtsStatus.paused)),
),
),
),
@@ -1357,6 +1444,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
content: chapter.content,
contentKey: chapter.id,
title: 'Chương ${chapter.number}: ${chapter.title}',
nextChapterId: chapter.nextChapterId,
chapterNumber: chapter.number,
apiBaseUrl: AppConfig.baseUrl,
),
),
);
@@ -11,6 +11,9 @@ class TtsPlayerWidget extends ConsumerWidget {
required this.content,
this.contentKey,
this.title,
this.nextChapterId,
this.chapterNumber,
this.apiBaseUrl,
this.includeTitleOnStart = true,
this.resolveStartParagraphIndex,
this.onStarted,
@@ -20,6 +23,9 @@ class TtsPlayerWidget extends ConsumerWidget {
final String content;
final String? contentKey;
final String? title;
final String? nextChapterId;
final int? chapterNumber;
final String? apiBaseUrl;
final bool includeTitleOnStart;
final int Function()? resolveStartParagraphIndex;
final VoidCallback? onStarted;
@@ -39,6 +45,8 @@ class TtsPlayerWidget extends ConsumerWidget {
return;
}
notifier.clearPendingAutoStartChapter();
unawaited(
notifier.startReading(
content,
@@ -46,6 +54,9 @@ class TtsPlayerWidget extends ConsumerWidget {
startParagraphIndex: resolveStartParagraphIndex?.call(),
contentKey: contentKey,
title: title,
nextChapterId: nextChapterId,
chapterNumber: chapterNumber,
apiBaseUrl: apiBaseUrl,
includeTitle: includeTitleOnStart,
),
);
@@ -6,6 +6,7 @@ import '../../../core/models/reading_settings.dart';
import '../../../core/network/providers.dart';
import '../../../core/storage/local_store.dart';
import '../../../core/storage/offline_cache.dart';
import '../../bookshelf/providers/bookshelf_provider.dart';
// ─── Chapter content ─────────────────────────────────────────────────────────
@@ -94,12 +95,28 @@ class ReaderNotifier extends StateNotifier<ReadingProgress?> {
// Also notify server (fire and forget)
try {
final client = _ref.read(apiClientProvider);
await client.dio.post('/api/user/reading-progress', data: {
final res = await client.dio.post('/api/user/reading-progress', data: {
'novelId': _novelId,
'chapterId': chapterId,
'chapterNumber': chapterNumber,
'progress': offset,
});
final data = res.data;
Map<String, dynamic>? bookmarkJson;
if (data is Map<String, dynamic>) {
final bookmark = data['bookmark'];
if (bookmark is Map<String, dynamic>) {
bookmarkJson = bookmark;
}
}
_ref.read(bookshelfProvider.notifier).syncProgress(
novelId: _novelId!,
chapterId: chapterId,
chapterNumber: chapterNumber,
serverBookmark: bookmarkJson,
);
} catch (_) {}
}
+14 -1
View File
@@ -5,6 +5,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import '../../../core/config/app_config.dart';
enum TtsStatus { idle, playing, paused }
const double kTtsBaseSpeechRate = 0.9;
@@ -635,12 +637,19 @@ class TtsNotifier extends StateNotifier<TtsState> {
int? startCharOffset,
String? contentKey,
String? title,
String? nextChapterId,
int? chapterNumber,
String? apiBaseUrl,
bool includeTitle = true,
}) async {
if (!_initialized) {
await (_initFuture ?? _init());
}
// A direct start request (tap sentence/play button) should win over any
// queued chapter auto-start from previous navigation/completion events.
state = state.copyWith(clearPendingAutoStartChapterId: true);
_segments = _buildSegments(
content,
title: title,
@@ -685,14 +694,18 @@ class TtsNotifier extends StateNotifier<TtsState> {
try {
await _mediaChannel.invokeMethod<void>('startReading', {
'content': content,
'contentKey': contentKey,
'title': title,
'nextChapterId': nextChapterId,
'chapterNumber': chapterNumber,
'apiBaseUrl': apiBaseUrl ?? AppConfig.baseUrl,
'startIndex': validIndex,
'speed': state.speed,
'language': state.language,
'voiceName': state.voiceName,
'backgroundModeEnabled': state.backgroundModeEnabled,
'segments': _segments.map((segment) => segment.toMap()).toList(),
'includeTitle': includeTitle,
});
} on PlatformException {
await _startFallbackReading(
@@ -1,25 +1,48 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/router/route_names.dart';
import '../../../core/storage/local_store.dart';
class SplashScreen extends StatefulWidget {
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
class _SplashScreenState extends ConsumerState<SplashScreen> {
Timer? _redirectTimer;
bool _isRestorableRoute(String path) {
if (path.isEmpty || path == RouteNames.splash) return false;
return path == RouteNames.home ||
path == RouteNames.login ||
path == RouteNames.search ||
path.startsWith('${RouteNames.search}?') ||
path == RouteNames.genres ||
path == RouteNames.bookshelf ||
path == RouteNames.profile ||
path == RouteNames.settings ||
path.startsWith('/novel/') ||
path.startsWith('/reader/') ||
path.startsWith('/comments/');
}
@override
void initState() {
super.initState();
_redirectTimer = Timer(const Duration(milliseconds: 700), () {
_redirectTimer = Timer(const Duration(milliseconds: 700), () async {
if (!mounted) return;
final lastPath = await ref.read(localStoreProvider).loadLastRoutePath();
if (!mounted) return;
if (lastPath != null && _isRestorableRoute(lastPath)) {
context.go(lastPath);
return;
}
context.go(RouteNames.home);
});
}