feat: Implement TTS playback store and enhance reading progress synchronization
- 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:
@@ -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 (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user