802 lines
23 KiB
Dart
802 lines
23 KiB
Dart
// ============================================================
|
||
// 闲言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和在线TTS(Edge 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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|