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
@@ -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,96 +1,240 @@
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: [
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,
);
},
)
else
IconButton.filled(
icon: const Icon(Icons.pause),
onPressed: notifier.pause,
),
IconButton(
icon: const Icon(Icons.stop),
onPressed: tts.status != TtsStatus.idle ? notifier.stop : null,
),
IconButton(
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,
),
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
if (tts.totalParagraphs > 0)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
'${tts.paragraphIndex + 1}/${tts.totalParagraphs}',
style: Theme.of(context).textTheme.labelSmall,
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: tts.status != TtsStatus.idle ? notifier.skipBack : null,
),
),
if (tts.voiceName != null)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Text(
tts.voiceName!,
if (!tts.isPlaying)
IconButton.filled(
icon: const Icon(Icons.play_arrow),
onPressed: () => start(),
)
else
IconButton.filled(
icon: const Icon(Icons.pause),
onPressed: notifier.pause,
),
IconButton(
icon: const Icon(Icons.stop),
onPressed: tts.status != TtsStatus.idle ? notifier.stop : null,
),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: tts.status != TtsStatus.idle ? notifier.skipForward : null,
),
speedButton(),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 12,
runSpacing: 4,
children: [
if (tts.totalParagraphs > 0)
Text(
'${tts.paragraphIndex + 1}/${tts.totalParagraphs}',
style: Theme.of(context).textTheme.labelSmall,
),
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();
}