Files
reader-app/lib/features/reader/presentation/tts_player_widget.dart
T
virtus 6946083aee
Build Android APK / build-apk (push) Failing after 4m37s
feat: Enhance chapter list and TTS functionality
- 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.
2026-04-07 18:49:29 +07:00

243 lines
8.0 KiB
Dart

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;
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: 10, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.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,
),
],
),
],
),
);
}
}