feat: Implement native Android MediaSession and foreground service for TTS playback

- Add `ReaderTtsMediaService` to handle background playback, media controls, and notifications on Android
- Integrate `MediaSessionCompat` to support external media controls and lock screen integration
- Add `ReaderTtsMediaBridge` for synchronized state communication between Kotlin and Flutter
- Update `TtsNotifier` to use the native Android service when available, with a fallback for other platforms
- Implement sentence-level highlighting and tapping to start reading from a specific location
- Update Android manifest with necessary permissions for foreground services and notifications
- Adjust TTS speech rate constants and improve playback health monitoring and recovery logic
This commit is contained in:
2026-04-10 18:56:36 +07:00
parent 2d41121b84
commit 76edaa25a4
9 changed files with 1706 additions and 214 deletions
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
@@ -74,22 +75,16 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
required bool isActiveParagraph,
required int highlightStart,
required int highlightEnd,
required Function(int charOffset) onSentenceTap,
}) {
if (!isActiveParagraph || highlightStart < 0 || highlightEnd <= highlightStart) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
);
}
final sentenceMatches = RegExp(r'[^.!?…]+[.!?…]*').allMatches(paragraph).toList();
final safeStart = highlightStart.clamp(0, paragraph.length);
final safeEnd = highlightEnd.clamp(0, paragraph.length);
if (safeEnd <= safeStart) {
if (sentenceMatches.isEmpty) {
return SelectableText(
paragraph,
textAlign: textAlign,
style: style,
onTap: () => onSentenceTap(0),
);
}
@@ -98,16 +93,28 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
fontWeight: FontWeight.w600,
);
return RichText(
textAlign: textAlign,
text: TextSpan(
return SelectableText.rich(
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)),
],
children: sentenceMatches.map((match) {
final sentence = match.group(0)!;
final start = match.start;
final end = match.end;
final isCurrentSpoken = isActiveParagraph &&
highlightStart >= 0 &&
highlightEnd > highlightStart &&
start >= highlightStart &&
end <= highlightEnd;
return TextSpan(
text: sentence,
style: isCurrentSpoken ? highlightStyle : null,
recognizer: TapGestureRecognizer()..onTap = () => onSentenceTap(start),
);
}).toList(),
),
textAlign: textAlign,
);
}
@@ -181,6 +188,48 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
});
}
/// Handle TTS state transitions that require navigation or restarts.
/// Called once from [build] via [ref.listen] — safe to run side effects here.
void _onTtsStateChanged(TtsState? previous, TtsState next) {
// Guard: only act when something meaningful changed.
if (previous == null) return;
final chapterAsync = ref.read(chapterProvider(widget.chapterId));
final chapter = chapterAsync.valueOrNull;
if (chapter == null) return;
// Chapter-completion → auto-advance to next chapter.
if (next.completedCount > _lastTtsCompletedCount) {
_lastTtsCompletedCount = next.completedCount;
if (next.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));
});
}
return;
}
// Pending auto-start for this chapter (set by previous chapter's completion).
if (next.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;
});
}
}
@override
void dispose() {
_uiAutoHideTimer?.cancel();
@@ -686,7 +735,7 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Wrap(
spacing: 8,
runSpacing: 8,
children: [0.35, 0.45, 0.55, 0.65, 0.8, 1.0].map((speed) {
children: [0.45, 0.675, 0.9, 1.125, 1.35, 1.8].map((speed) {
final selected = tts.speed == speed;
return ChoiceChip(
label: Text(formatTtsSpeedLabel(speed)),
@@ -790,6 +839,9 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
Widget build(BuildContext context) {
final chapterAsync = ref.watch(chapterProvider(widget.chapterId));
final settings = ref.watch(readingSettingsProvider);
// Side-effects for TTS state changes (navigation, auto-start).
ref.listen<TtsState>(ttsProvider, _onTtsStateChanged);
Color readerBackground;
Color readerTextColor;
Color readerMutedColor;
@@ -835,33 +887,6 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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((_) {
@@ -990,6 +1015,15 @@ class _ReaderScreenState extends ConsumerState<ReaderScreen> {
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,
);
},
),
),
],
@@ -30,7 +30,7 @@ class TtsPlayerWidget extends ConsumerWidget {
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];
const speeds = [0.45, 0.675, 0.9, 1.125, 1.35, 1.8];
Future<void> start() async {
if (tts.status == TtsStatus.paused) {