Files
xianyan/lib/shared/widgets/plugin/tts_player_sheet.dart
2026-06-08 09:29:24 +08:00

802 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 闲言APP — 朗读控制弹窗
// 创建时间: 2026-05-25
// 更新时间: 2026-06-08
// 作用: 朗读控制底部弹窗支持系统TTS和在线TTS
// 上次更新: TTS回退Toast提示文案统一为"已切换到在线朗读"
// ============================================================
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:stupid_simple_sheet/stupid_simple_sheet.dart';
import '../../../core/services/audio/online_tts_service.dart';
import '../../../core/services/audio/tts_service.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/logger.dart';
import '../../../features/mine/settings/providers/plugin_provider.dart';
import '../containers/bottom_sheet.dart';
import '../feedback/app_toast.dart';
// ============================================================
// TTS 朗读控制弹窗
// ============================================================
/// 朗读控制底部弹窗
///
/// 支持系统TTS和在线TTSEdge TTS两种引擎
/// 提供播放/暂停/停止控制、语速调节、引擎切换等功能。
/// 打开时自动开始朗读,关闭时自动停止。
class TtsPlayerSheet extends ConsumerStatefulWidget {
const TtsPlayerSheet({super.key, required this.text});
/// 要朗读的文本
final String text;
/// 显示朗读控制弹窗
static Future<void> show(BuildContext context, {required String text}) {
return AppBottomSheet.showCustom(
context: context,
builder: (_) => TtsPlayerSheet(text: text),
snappingConfig: const SheetSnappingConfig([0.7], initialSnap: 0.7),
);
}
@override
ConsumerState<TtsPlayerSheet> createState() => _TtsPlayerSheetState();
}
class _TtsPlayerSheetState extends ConsumerState<TtsPlayerSheet> {
// ---- 状态 ----
/// 当前TTS播放状态
TtsState _ttsState = TtsState.idle;
/// 当前引擎类型: 'system' / 'online'
String _engineType = 'system';
/// 语速 (0.5~2.0)
double _speed = 1.0;
/// 播放进度 0.0~1.0
double _progress = 0.0;
/// 卡拉OK高亮 — 当前词起始位置
int _currentWordStart = 0;
/// 卡拉OK高亮 — 当前词结束位置
int _currentWordEnd = 0;
/// 卡拉OK高亮 — 是否正在高亮
bool _isHighlighting = false;
// ---- 订阅 ----
StreamSubscription<TtsState>? _systemStateSub;
StreamSubscription<(int, int, String)>? _systemProgressSub;
StreamSubscription<TtsState>? _onlineStateSub;
StreamSubscription<String>? _onlineErrorSub;
StreamSubscription<String>? _systemErrorSub;
/// 是否正在回退中(防止重复回退)
bool _isFallingBack = false;
@override
void initState() {
super.initState();
_initFromPlugin();
_subscribeSystemTts();
_subscribeOnlineTts();
_subscribeSystemTtsErrors();
// 延迟到构建完成后启动朗读避免在initState中修改provider导致
// "Tried to modify a provider while the widget tree was building" 错误
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _startPlaying();
});
}
/// 从插件状态读取配置
void _initFromPlugin() {
final plugin = ref.read(pluginProvider);
_engineType = plugin.ttsEngineType;
_speed = plugin.ttsSpeed;
}
// ============================================================
// 订阅 TTS 状态流
// ============================================================
/// 订阅系统TTS状态
void _subscribeSystemTts() {
final tts = TtsService.instance;
_systemStateSub = tts.onStateChanged.listen((state) {
if (!mounted || _engineType != 'system') return;
setState(() {
_ttsState = state;
if (state == TtsState.idle) {
_progress = 0.0;
_isHighlighting = false;
_currentWordStart = 0;
_currentWordEnd = 0;
}
});
});
_systemProgressSub = tts.onProgress.listen((data) {
if (!mounted || _engineType != 'system') return;
final (start, end, _) = data;
if (end > 0) {
setState(() {
_progress = (start / end).clamp(0.0, 1.0);
_currentWordStart = start;
_currentWordEnd = end;
_isHighlighting = true;
});
}
});
}
/// 订阅在线TTS状态
void _subscribeOnlineTts() {
final onlineTts = OnlineTtsService.instance;
_onlineStateSub = onlineTts.onStateChanged.listen((state) {
if (!mounted || _engineType != 'online') return;
setState(() {
_ttsState = state;
if (state == TtsState.idle) {
_progress = 0.0;
_isHighlighting = false;
_currentWordStart = 0;
_currentWordEnd = 0;
}
});
});
_onlineErrorSub = onlineTts.onError.listen((msg) {
if (!mounted) return;
Log.e('TtsPlayerSheet: 在线TTS错误 - $msg');
_showErrorToast(msg);
});
}
/// 订阅系统TTS错误用于自动回退到在线TTS
void _subscribeSystemTtsErrors() {
_systemErrorSub = TtsService.instance.onError.listen((msg) {
if (!mounted || _engineType != 'system' || _isFallingBack) return;
Log.w('TtsPlayerSheet: 系统TTS错误 - $msg准备回退到在线TTS');
_fallbackToOnlineTts(msg);
});
}
/// 系统TTS失败时自动回退到在线TTS
Future<void> _fallbackToOnlineTts(String errorMsg) async {
if (_isFallingBack) return;
_isFallingBack = true;
Log.i('TtsPlayerSheet: 自动回退到在线TTS');
// 停止系统TTS
await TtsService.instance.stop();
// 切换到在线TTS
final plugin = ref.read(pluginProvider);
setState(() {
_engineType = 'online';
_ttsState = TtsState.idle;
_progress = 0.0;
});
ref.read(pluginProvider.notifier).setTtsEngineType('online');
// 使用在线TTS重新朗读
try {
await OnlineTtsService.instance.speak(
widget.text,
voice: plugin.ttsOnlineVoice,
speed: _speed,
pitch: plugin.ttsPitch,
);
if (mounted) {
AppToast.showWarning('已切换到在线朗读');
}
} catch (e) {
Log.e('TtsPlayerSheet: 在线TTS回退也失败', e);
if (mounted) {
_showErrorToast('系统TTS和在线TTS均不可用请检查网络');
}
}
_isFallingBack = false;
}
// ============================================================
// 播放控制
// ============================================================
/// 开始朗读
Future<void> _startPlaying() async {
ref.read(pluginProvider.notifier).incrementTtsCount();
Log.i('🔊 TtsPlayerSheet: 开始朗读 (引擎=$_engineType)');
if (_engineType == 'online') {
final plugin = ref.read(pluginProvider);
await OnlineTtsService.instance.speak(
widget.text,
voice: plugin.ttsOnlineVoice,
speed: _speed,
pitch: plugin.ttsPitch,
);
} else {
// 系统TTS先初始化检查引擎是否就绪
await TtsService.instance.init();
if (!TtsService.instance.isEngineReady) {
// 引擎未就绪直接回退到在线TTS
Log.w('TtsPlayerSheet: 系统TTS引擎未就绪直接回退到在线TTS');
await _fallbackToOnlineTts('系统TTS引擎未就绪');
return;
}
final plugin = ref.read(pluginProvider);
await TtsService.instance.setSpeed(_speed / 2.0);
await TtsService.instance.setPitch(plugin.ttsPitch);
await TtsService.instance.setVolume(plugin.ttsVolume);
await TtsService.instance.speak(widget.text);
}
}
/// 切换播放/暂停
Future<void> _togglePlayPause() async {
if (_ttsState == TtsState.speaking) {
if (_engineType == 'system') {
await TtsService.instance.pause();
} else {
await OnlineTtsService.instance.stop();
}
} else if (_ttsState == TtsState.paused || _ttsState == TtsState.idle) {
await _startPlaying();
}
}
/// 停止朗读
Future<void> _stop() async {
if (_engineType == 'online') {
await OnlineTtsService.instance.stop();
} else {
await TtsService.instance.stop();
}
}
/// 切换引擎类型
Future<void> _switchEngine(String type) async {
if (type == _engineType) return;
await _stop();
setState(() {
_engineType = type;
_ttsState = TtsState.idle;
_progress = 0.0;
});
ref.read(pluginProvider.notifier).setTtsEngineType(type);
Log.i('🔊 TtsPlayerSheet: 切换引擎为 $type');
await _startPlaying();
}
/// 更新语速
Future<void> _updateSpeed(double value) async {
setState(() => _speed = value);
ref.read(pluginProvider.notifier).setTtsSpeed(value);
if (_engineType == 'system') {
await TtsService.instance.setSpeed(_speed / 2.0);
}
}
/// 自动检测文本语言并切换语音
void _autoDetectLanguage() {
final text = widget.text;
String langCode = 'zh';
final cjkRegex = RegExp(r'[\u4e00-\u9fff]');
final hiraganaRegex = RegExp(r'[\u3040-\u309f]');
final hangulRegex = RegExp(r'[\uac00-\ud7af]');
final latinRegex = RegExp(r'[a-zA-Z]');
if (hiraganaRegex.hasMatch(text)) {
langCode = 'ja';
} else if (hangulRegex.hasMatch(text)) {
langCode = 'ko';
} else if (cjkRegex.hasMatch(text)) {
langCode = 'zh';
} else if (latinRegex.hasMatch(text)) {
langCode = 'en';
}
Log.i('🌐 TtsPlayerSheet: 自动检测语言=$langCode');
if (_engineType == 'online') {
final voice = OnlineTtsService.autoSelectVoice(langCode);
ref.read(pluginProvider.notifier).setTtsOnlineVoice(voice);
setState(() {});
} else {
TtsService.instance.setLanguage(TtsService.mapToTtsLanguage(langCode));
}
_restartWithNewConfig();
}
/// 使用新配置重新开始朗读
Future<void> _restartWithNewConfig() async {
await _stop();
await _startPlaying();
}
// ============================================================
// 错误提示
// ============================================================
/// 显示错误提示
void _showErrorToast(String msg) {
if (!mounted) return;
showCupertinoDialog<void>(
context: context,
barrierDismissible: true,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('朗读出错'),
content: Text(msg),
actions: [
CupertinoDialogAction(
child: const Text('确定'),
onPressed: () => Navigator.of(ctx).pop(),
),
],
),
);
}
// ============================================================
// 生命周期
// ============================================================
@override
void dispose() {
_stop();
_systemStateSub?.cancel();
_systemProgressSub?.cancel();
_onlineStateSub?.cancel();
_onlineErrorSub?.cancel();
_systemErrorSub?.cancel();
super.dispose();
}
// ============================================================
// UI 构建
// ============================================================
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildTitle(ext),
const SizedBox(height: AppSpacing.md),
_buildPlayerCard(ext),
const SizedBox(height: AppSpacing.lg),
_buildSettingsSection(ext),
const SizedBox(height: AppSpacing.lg),
_buildCloseButton(ext),
const SizedBox(height: AppSpacing.md),
],
),
),
);
}
/// 标题区域
Widget _buildTitle(AppThemeExtension ext) {
return Row(
children: [
Text(
'🔊 文本朗读',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
),
const Spacer(),
if (_ttsState == TtsState.loading)
CupertinoActivityIndicator(color: ext.iconSecondary),
],
);
}
/// 播放器卡片 — 紫色渐变背景
Widget _buildPlayerCard(AppThemeExtension ext) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
ext.accent.withValues(alpha: 0.15),
ext.iconTintPurple.withValues(alpha: 0.12),
],
),
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: ext.accent.withValues(alpha: 0.2),
width: 0.5,
),
),
child: Column(
children: [
_buildPlayerHeader(ext),
const SizedBox(height: AppSpacing.sm),
_buildTextPreview(ext),
const SizedBox(height: AppSpacing.md),
_buildControlsRow(ext),
const SizedBox(height: AppSpacing.sm),
_buildProgressBar(ext),
],
),
);
}
/// 播放器头部 — 状态标签 + 语速
Widget _buildPlayerHeader(AppThemeExtension ext) {
final statusLabel = switch (_ttsState) {
TtsState.speaking => '正在朗读',
TtsState.paused => '已暂停',
TtsState.loading => '加载中...',
TtsState.idle => '点击播放',
};
final displaySpeed = _speed.toStringAsFixed(1);
return Row(
children: [
const Text('🎙️', style: TextStyle(fontSize: 18)),
const SizedBox(width: AppSpacing.xs),
Text(
statusLabel,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.15),
borderRadius: AppRadius.fullBorder,
),
child: Text(
'$displaySpeed x',
style: AppTypography.caption2.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
/// 文本预览 — 卡拉OK高亮效果
Widget _buildTextPreview(AppThemeExtension ext) {
final text = widget.text;
if (!_isHighlighting || _currentWordStart >= text.length) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 48),
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTypography.caption1.copyWith(color: ext.textSecondary),
),
);
}
final safeEnd = _currentWordEnd.clamp(0, text.length);
final safeStart = _currentWordStart.clamp(0, safeEnd);
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 48),
child: RichText(
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: AppTypography.caption1.copyWith(color: ext.textHint),
children: [
TextSpan(text: text.substring(0, safeStart)),
TextSpan(
text: text.substring(safeStart, safeEnd),
style: AppTypography.caption1.copyWith(
color: ext.accent,
fontWeight: FontWeight.w700,
),
),
TextSpan(text: text.substring(safeEnd)),
],
),
),
);
}
/// 控制按钮行 — 播放/暂停 + 停止
Widget _buildControlsRow(AppThemeExtension ext) {
final isPlaying = _ttsState == TtsState.speaking;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: _togglePlayPause,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: ext.accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: ext.accent.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: _ttsState == TtsState.loading
? CupertinoActivityIndicator(color: ext.textOnAccent)
: Icon(
isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill,
color: ext.textOnAccent,
size: 24,
),
),
),
),
const SizedBox(width: AppSpacing.lg),
GestureDetector(
onTap: _stop,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: ext.bgSecondary,
shape: BoxShape.circle,
),
child: Icon(
CupertinoIcons.stop_fill,
color: ext.iconSecondary,
size: 20,
),
),
),
],
);
}
/// 进度条
Widget _buildProgressBar(AppThemeExtension ext) {
return ClipRRect(
borderRadius: AppRadius.fullBorder,
child: LinearProgressIndicator(
value: _progress > 0 ? _progress : null,
backgroundColor: ext.overlaySubtle,
valueColor: AlwaysStoppedAnimation(ext.accent),
minHeight: 3,
),
);
}
/// 设置区域 — 引擎选择 + 语速滑块
Widget _buildSettingsSection(AppThemeExtension ext) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEngineSelector(ext),
const SizedBox(height: AppSpacing.md),
_buildSpeedSlider(ext),
],
);
}
/// 引擎选择器
Widget _buildEngineSelector(AppThemeExtension ext) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Row(
children: [
Text(
'朗读引擎',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
GestureDetector(
onTap: _autoDetectLanguage,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 4,
),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.smBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.globe, size: 14, color: ext.accent),
const SizedBox(width: 4),
Text(
'自动',
style: AppTypography.caption2.copyWith(
color: ext.accent,
),
),
],
),
),
),
],
),
),
Row(
children: [
_buildEngineChip(
ext: ext,
label: '📱 系统TTS',
value: 'system',
isSelected: _engineType == 'system',
),
const SizedBox(width: AppSpacing.sm),
_buildEngineChip(
ext: ext,
label: '☁️ 在线TTS',
value: 'online',
isSelected: _engineType == 'online',
),
],
),
],
);
}
/// 引擎选择芯片
Widget _buildEngineChip({
required AppThemeExtension ext,
required String label,
required String value,
required bool isSelected,
}) {
return Expanded(
child: GestureDetector(
onTap: () => _switchEngine(value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.12)
: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: isSelected
? ext.accent.withValues(alpha: 0.4)
: ext.overlaySubtle,
width: isSelected ? 1.5 : 0.5,
),
),
child: Center(
child: Text(
label,
style: AppTypography.subhead.copyWith(
color: isSelected ? ext.accent : ext.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
);
}
/// 语速滑块
Widget _buildSpeedSlider(AppThemeExtension ext) {
final displaySpeed = _speed.toStringAsFixed(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'语速',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
Text(
'$displaySpeed x',
style: AppTypography.caption1.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
CupertinoSlider(
value: _speed,
min: 0.5,
max: 2.0,
divisions: 15,
activeColor: ext.accent,
thumbColor: ext.textOnAccent,
onChanged: _updateSpeed,
),
],
);
}
/// 关闭按钮
Widget _buildCloseButton(AppThemeExtension ext) {
return SizedBox(
width: double.infinity,
height: 48,
child: CupertinoButton(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
padding: EdgeInsets.zero,
onPressed: () {
_stop();
Navigator.of(context).pop();
},
child: Text(
'关闭',
style: AppTypography.callout.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
);
}
}