Refactor chapter list provider and improve TTS functionality
- Removed the constant chapterPageSize and refactored ChapterListQuery to use a simpler approach for fetching chapters. - Updated the chapter list provider to handle fetching all chapters in a single request with pagination. - Enhanced error handling for fetching chapters by resolving canonical IDs when necessary. - Modified TTS functionality to ensure proper handling of Android fallback reading and improved error management. - Added a new setting to enable/disable TTS on sentence tap. - Updated UI components in the reader screen for better user experience and added navigation buttons for chapters. - Bumped version to 1.0.3+4 in pubspec.yaml.
This commit is contained in:
@@ -179,7 +179,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
}
|
||||
|
||||
void _maybeAutoScrollToTtsParagraph(TtsState tts, int paragraphCount) {
|
||||
if (tts.status == TtsStatus.idle) return;
|
||||
if (tts.status != TtsStatus.playing) return;
|
||||
final index = tts.activeParagraphIndex;
|
||||
if (index < 0 || index >= paragraphCount) return;
|
||||
if (index == _lastAutoScrolledParagraph) return;
|
||||
@@ -189,6 +189,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
if (!mounted) return;
|
||||
final ctx = _paragraphKeys[index].currentContext;
|
||||
if (ctx == null) return;
|
||||
// Clear any active text-selection focus before programmatic scrolling.
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
Scrollable.ensureVisible(
|
||||
ctx,
|
||||
alignment: 0.22,
|
||||
@@ -455,11 +457,8 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
builder: (sheetContext) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final tocPage = ((currentChapter.number - 1) ~/ chapterPageSize) + 1;
|
||||
final chaptersAsync = ref.watch(
|
||||
chapterListProvider(
|
||||
ChapterListQuery(novelId: currentChapter.novelId, page: tocPage),
|
||||
),
|
||||
chapterListProvider(currentChapter.novelId),
|
||||
);
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.82,
|
||||
@@ -489,12 +488,20 @@ 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: (pageData) {
|
||||
final chapters = pageData.chapters;
|
||||
data: (chapters) {
|
||||
if (chapters.isEmpty) {
|
||||
return const Center(child: Text('Chưa có danh sách chương.'));
|
||||
}
|
||||
// Find index of current chapter for auto-scroll
|
||||
final currentIndex = chapters.indexWhere((ch) => ch.id == currentChapter.id);
|
||||
final scrollController = ScrollController(
|
||||
initialScrollOffset: currentIndex > 0
|
||||
? currentIndex * 48.0 // Approximate height per ListTile
|
||||
: 0,
|
||||
);
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
itemCount: chapters.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -847,6 +854,22 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: settings.enableSentenceTapTts,
|
||||
onChanged: (enabled) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(readingSettingsProvider.notifier)
|
||||
.setSentenceTapTtsEnabled(enabled),
|
||||
);
|
||||
},
|
||||
title: const Text('Bật chạm câu để phát TTS'),
|
||||
subtitle: const Text(
|
||||
'Tắt để tránh chạm nhầm làm bắt đầu TTS.',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -1166,6 +1189,12 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
.titleLarge
|
||||
?.copyWith(color: readerTextColor),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_NavButtons(
|
||||
chapter: chapter,
|
||||
onGoPrevious: () => _goToPreviousChapter(chapter),
|
||||
onGoNext: () => _goToNextChapter(chapter),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (chapter.content.trim().isEmpty)
|
||||
Text(
|
||||
@@ -1197,32 +1226,44 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Padding(
|
||||
key: _paragraphKeys[index],
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == paragraphs.length - 1
|
||||
? 0
|
||||
: settings.paragraphSpacing,
|
||||
),
|
||||
child: _buildParagraphText(
|
||||
context: context,
|
||||
sentenceSlices: sentenceSlices,
|
||||
textAlign: textAlign,
|
||||
style: paragraphStyle,
|
||||
highlightStyle: paragraphHighlightStyle,
|
||||
isActiveParagraph: shouldHighlightTts &&
|
||||
tts.activeParagraphIndex == index,
|
||||
highlightStart: tts.progressStart,
|
||||
highlightEnd: tts.progressEnd,
|
||||
onSentenceTap: (charOffset) {
|
||||
ref.read(ttsProvider.notifier).startReading(
|
||||
chapter.content,
|
||||
contentKey: chapter.id,
|
||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||
startParagraphIndex: index,
|
||||
startCharOffset: charOffset,
|
||||
);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
key: _paragraphKeys[index],
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == paragraphs.length - 1
|
||||
? 0
|
||||
: settings.paragraphSpacing,
|
||||
),
|
||||
child: _buildParagraphText(
|
||||
context: context,
|
||||
sentenceSlices: sentenceSlices,
|
||||
textAlign: textAlign,
|
||||
style: paragraphStyle,
|
||||
highlightStyle: paragraphHighlightStyle,
|
||||
isActiveParagraph: shouldHighlightTts &&
|
||||
tts.activeParagraphIndex == index,
|
||||
highlightStart: tts.progressStart,
|
||||
highlightEnd: tts.progressEnd,
|
||||
onSentenceTap: (charOffset) {
|
||||
final hasActiveTtsSession =
|
||||
tts.contentKey == chapter.id &&
|
||||
(tts.status == TtsStatus.playing ||
|
||||
tts.status == TtsStatus.paused);
|
||||
final canStartFromSentence =
|
||||
settings.enableSentenceTapTts || hasActiveTtsSession;
|
||||
if (!canStartFromSentence) {
|
||||
return;
|
||||
}
|
||||
ref.read(ttsProvider.notifier).startReading(
|
||||
chapter.content,
|
||||
contentKey: chapter.id,
|
||||
title: 'Chương ${chapter.number}: ${chapter.title}',
|
||||
startParagraphIndex: index,
|
||||
startCharOffset: charOffset,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1614,20 +1655,18 @@ class _NavButtons extends StatelessWidget {
|
||||
children: [
|
||||
if (chapter.prevChapterId != null)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
child: OutlinedButton(
|
||||
onPressed: onGoPrevious,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
label: Text('Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||
child: Text('< Chương ${chapter.prevChapterNumber ?? '?'}'),
|
||||
),
|
||||
),
|
||||
if (chapter.prevChapterId != null && chapter.nextChapterId != null)
|
||||
const SizedBox(width: 12),
|
||||
if (chapter.nextChapterId != null)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
child: FilledButton(
|
||||
onPressed: onGoNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
label: Text('Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||
child: Text('> Chương ${chapter.nextChapterNumber ?? '?'}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -145,6 +145,10 @@ class ReadingSettingsNotifier extends StateNotifier<ReadingSettings> {
|
||||
final localStore = _ref.read(localStoreProvider);
|
||||
await localStore.saveReadingSettings(settings);
|
||||
}
|
||||
|
||||
Future<void> setSentenceTapTtsEnabled(bool enabled) async {
|
||||
await update(state.copyWith(enableSentenceTapTts: enabled));
|
||||
}
|
||||
}
|
||||
|
||||
final readingSettingsProvider =
|
||||
|
||||
@@ -155,6 +155,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
int _pendingFallbackIndex = -1;
|
||||
bool _didStartCurrentFallbackUtterance = false;
|
||||
bool _hasPromptedNotificationSettings = false;
|
||||
bool _androidFallbackReady = false;
|
||||
|
||||
bool get _useNativeAndroidMediaService => Platform.isAndroid;
|
||||
|
||||
@@ -315,6 +316,73 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _ensureAndroidFallbackReady() async {
|
||||
if (_androidFallbackReady) return;
|
||||
|
||||
await _tts.awaitSpeakCompletion(true);
|
||||
await _tts.setSharedInstance(true);
|
||||
await _configureVietnameseVoiceWithFlutterTts();
|
||||
await _tts.setSpeechRate(state.speed);
|
||||
await _tts.setVolume(1.0);
|
||||
await _tts.setPitch(1.0);
|
||||
|
||||
_tts.setStartHandler(() {
|
||||
_didStartCurrentFallbackUtterance = true;
|
||||
final index = _pendingFallbackIndex;
|
||||
if (index >= 0 && index < _segments.length) {
|
||||
final segment = _segments[index];
|
||||
state = state.copyWith(
|
||||
status: TtsStatus.playing,
|
||||
paragraphIndex: index,
|
||||
activeParagraphIndex: segment.paragraphIndex,
|
||||
progressStart: segment.start,
|
||||
progressEnd: segment.end,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(status: TtsStatus.playing);
|
||||
}
|
||||
});
|
||||
|
||||
_tts.setCompletionHandler(() {
|
||||
// Fallback playback progression is driven by _playFallbackFromGeneration.
|
||||
});
|
||||
|
||||
_tts.setErrorHandler((_) {
|
||||
if (_isInterruptingPlayback) return;
|
||||
_pendingFallbackIndex = -1;
|
||||
_didStartCurrentFallbackUtterance = false;
|
||||
state = state.copyWith(
|
||||
status: TtsStatus.idle,
|
||||
activeParagraphIndex: -1,
|
||||
progressStart: -1,
|
||||
progressEnd: -1,
|
||||
);
|
||||
});
|
||||
|
||||
_androidFallbackReady = true;
|
||||
}
|
||||
|
||||
Future<void> _startFallbackReading({
|
||||
required int validIndex,
|
||||
required _TtsSegment selectedSegment,
|
||||
required String? contentKey,
|
||||
}) async {
|
||||
await _ensureAndroidFallbackReady();
|
||||
final sessionId = await _interruptFallbackPlayback();
|
||||
|
||||
state = state.copyWith(
|
||||
status: TtsStatus.playing,
|
||||
paragraphIndex: validIndex,
|
||||
totalParagraphs: _segments.length,
|
||||
activeParagraphIndex: selectedSegment.paragraphIndex,
|
||||
progressStart: selectedSegment.start,
|
||||
progressEnd: selectedSegment.end,
|
||||
contentKey: contentKey,
|
||||
);
|
||||
|
||||
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||
}
|
||||
|
||||
void _handleAndroidMediaEvent(dynamic event) {
|
||||
_applyAndroidSnapshot(event);
|
||||
}
|
||||
@@ -372,6 +440,7 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
|
||||
// Keep natural sentence flow while removing symbols that are usually read out noisily.
|
||||
final cleaned = raw
|
||||
.replaceAll(RegExp(r'["“”]'), ' ')
|
||||
.replaceAll(RegExp(r'[_$#^*+=~`|<>\\\[\]{}]'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
@@ -614,33 +683,32 @@ class TtsNotifier extends StateNotifier<TtsState> {
|
||||
contentKey: contentKey,
|
||||
);
|
||||
|
||||
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||
'contentKey': contentKey,
|
||||
'title': title,
|
||||
'startIndex': validIndex,
|
||||
'speed': state.speed,
|
||||
'language': state.language,
|
||||
'voiceName': state.voiceName,
|
||||
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
||||
});
|
||||
try {
|
||||
await _mediaChannel.invokeMethod<void>('startReading', {
|
||||
'contentKey': contentKey,
|
||||
'title': title,
|
||||
'startIndex': validIndex,
|
||||
'speed': state.speed,
|
||||
'language': state.language,
|
||||
'voiceName': state.voiceName,
|
||||
'backgroundModeEnabled': state.backgroundModeEnabled,
|
||||
'segments': _segments.map((segment) => segment.toMap()).toList(),
|
||||
});
|
||||
} on PlatformException {
|
||||
await _startFallbackReading(
|
||||
validIndex: validIndex,
|
||||
selectedSegment: selectedSegment,
|
||||
contentKey: contentKey,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionId = await _interruptFallbackPlayback();
|
||||
|
||||
state = state.copyWith(
|
||||
status: TtsStatus.playing,
|
||||
paragraphIndex: validIndex,
|
||||
totalParagraphs: _segments.length,
|
||||
activeParagraphIndex: -1,
|
||||
progressStart: -1,
|
||||
progressEnd: -1,
|
||||
await _startFallbackReading(
|
||||
validIndex: validIndex,
|
||||
selectedSegment: selectedSegment,
|
||||
contentKey: contentKey,
|
||||
);
|
||||
await _syncBackgroundMode();
|
||||
|
||||
unawaited(_playFallbackFromGeneration(validIndex, sessionId));
|
||||
}
|
||||
|
||||
Future<int> _interruptFallbackPlayback() async {
|
||||
|
||||
Reference in New Issue
Block a user