rtc_prd/airhub_app/lib/widgets/pill_progress_button.dart
seaislee1209 84243f2be4 feat: TTS语音合成 + 唱片架播放状态 + 气泡持续显示 + 音乐Prompt优化
- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成
- 新增 PillProgressButton 组件(药丸形进度按钮)
- 新增 TTSService 单例,后台生成不中断
- 音频保存到 Capybara audio/ 目录
- 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标)
- 播放时气泡持续显示当前歌名,暂停后隐藏
- 音乐总监Prompt去固定模板,歌名不再重复
- 新增 API 参考文档(豆包语音合成)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 22:51:26 +08:00

335 lines
9.5 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
enum TTSButtonState {
idle,
ready,
generating,
completed,
playing,
paused,
error,
}
class PillProgressButton extends StatefulWidget {
final TTSButtonState state;
final double progress;
final VoidCallback? onTap;
final double height;
const PillProgressButton({
super.key,
required this.state,
this.progress = 0.0,
this.onTap,
this.height = 48,
});
@override
State<PillProgressButton> createState() => _PillProgressButtonState();
}
class _PillProgressButtonState extends State<PillProgressButton>
with TickerProviderStateMixin {
late AnimationController _progressCtrl;
double _displayProgress = 0.0;
late AnimationController _glowCtrl;
late Animation<double> _glowAnim;
late AnimationController _waveCtrl;
bool _wasCompleted = false;
@override
void initState() {
super.initState();
_progressCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_progressCtrl.addListener(() => setState(() {}));
_glowCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_glowAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 35),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 65),
]).animate(CurvedAnimation(parent: _glowCtrl, curve: Curves.easeOut));
_glowCtrl.addListener(() => setState(() {}));
_waveCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_syncAnimations();
}
@override
void didUpdateWidget(PillProgressButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.progress != oldWidget.progress) {
if (oldWidget.state == TTSButtonState.completed &&
(widget.state == TTSButtonState.playing || widget.state == TTSButtonState.ready)) {
_displayProgress = 0.0;
} else {
_animateProgressTo(widget.progress);
}
}
if (widget.state == TTSButtonState.completed && !_wasCompleted) {
_wasCompleted = true;
_glowCtrl.forward(from: 0);
} else if (widget.state != TTSButtonState.completed) {
_wasCompleted = false;
}
_syncAnimations();
}
void _animateProgressTo(double target) {
final from = _displayProgress;
_progressCtrl.reset();
_progressCtrl.addListener(() {
final t = Curves.easeInOut.transform(_progressCtrl.value);
_displayProgress = from + (target - from) * t;
});
_progressCtrl.forward();
}
void _syncAnimations() {
if (widget.state == TTSButtonState.generating) {
if (!_waveCtrl.isAnimating) _waveCtrl.repeat();
} else {
if (_waveCtrl.isAnimating) {
_waveCtrl.stop();
_waveCtrl.value = 0;
}
}
}
@override
void dispose() {
_progressCtrl.dispose();
_glowCtrl.dispose();
_waveCtrl.dispose();
super.dispose();
}
bool get _showBorder =>
widget.state == TTSButtonState.generating ||
widget.state == TTSButtonState.completed ||
widget.state == TTSButtonState.playing ||
widget.state == TTSButtonState.paused;
@override
Widget build(BuildContext context) {
const borderColor = Color(0xFFE5E7EB);
const progressColor = Color(0xFFECCFA8);
const bgColor = Color(0xCCFFFFFF);
return GestureDetector(
onTap: widget.state == TTSButtonState.generating ? null : widget.onTap,
child: Container(
height: widget.height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.height / 2),
boxShadow: _glowAnim.value > 0
? [
BoxShadow(
color: progressColor.withOpacity(0.5 * _glowAnim.value),
blurRadius: 16 * _glowAnim.value,
spreadRadius: 2 * _glowAnim.value,
),
]
: null,
),
child: CustomPaint(
painter: PillBorderPainter(
progress: _showBorder ? _displayProgress.clamp(0.0, 1.0) : 0.0,
borderColor: borderColor,
progressColor: progressColor,
radius: widget.height / 2,
stroke: _showBorder ? 2.5 : 1.0,
bg: bgColor,
),
child: Center(child: _buildContent()),
),
),
);
}
Widget _buildContent() {
switch (widget.state) {
case TTSButtonState.idle:
return _label(Icons.headphones_rounded, '\u6717\u8bfb');
case TTSButtonState.generating:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _waveCtrl,
builder: (context, _) => CustomPaint(
size: const Size(20, 18),
painter: WavePainter(t: _waveCtrl.value, color: const Color(0xFFC99672)),
),
),
const SizedBox(width: 6),
const Text('\u751f\u6210\u4e2d',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF4B5563))),
],
);
case TTSButtonState.ready:
return _label(Icons.play_arrow_rounded, '\u64ad\u653e');
case TTSButtonState.completed:
return _label(Icons.play_arrow_rounded, '\u64ad\u653e');
case TTSButtonState.playing:
return _label(Icons.pause_rounded, '\u6682\u505c');
case TTSButtonState.paused:
return _label(Icons.play_arrow_rounded, '\u7ee7\u7eed');
case TTSButtonState.error:
return _label(Icons.refresh_rounded, '\u91cd\u8bd5', isError: true);
}
}
Widget _label(IconData icon, String text, {bool isError = false}) {
final c = isError ? const Color(0xFFEF4444) : const Color(0xFF4B5563);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: c),
const SizedBox(width: 4),
Text(text, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: c)),
],
);
}
}
class PillBorderPainter extends CustomPainter {
final double progress;
final Color borderColor;
final Color progressColor;
final double radius;
final double stroke;
final Color bg;
PillBorderPainter({
required this.progress,
required this.borderColor,
required this.progressColor,
required this.radius,
required this.stroke,
required this.bg,
});
@override
void paint(Canvas canvas, Size size) {
final r = radius.clamp(0.0, size.height / 2);
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height),
Radius.circular(r),
);
canvas.drawRRect(rrect, Paint()
..color = bg
..style = PaintingStyle.fill);
canvas.drawRRect(rrect, Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = stroke);
if (progress <= 0.001) return;
final straightH = size.width - 2 * r;
final halfTop = straightH / 2;
final arcLen = math.pi * r;
final totalLen = halfTop + arcLen + straightH + arcLen + halfTop;
final target = totalLen * progress;
final path = Path();
double done = 0;
final cx = size.width / 2;
path.moveTo(cx, 0);
var seg = math.min(halfTop, target - done);
path.lineTo(cx + seg, 0);
done += seg;
if (done >= target) { _drawPath(canvas, path); return; }
seg = math.min(arcLen, target - done);
_traceArc(path, size.width - r, r, r, -math.pi / 2, seg / r);
done += seg;
if (done >= target) { _drawPath(canvas, path); return; }
seg = math.min(straightH, target - done);
path.lineTo(size.width - r - seg, size.height);
done += seg;
if (done >= target) { _drawPath(canvas, path); return; }
seg = math.min(arcLen, target - done);
_traceArc(path, r, r, r, math.pi / 2, seg / r);
done += seg;
if (done >= target) { _drawPath(canvas, path); return; }
seg = math.min(halfTop, target - done);
path.lineTo(r + seg, 0);
_drawPath(canvas, path);
}
void _drawPath(Canvas canvas, Path path) {
canvas.drawPath(path, Paint()
..color = progressColor
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round);
}
void _traceArc(Path p, double cx, double cy, double r, double start, double sweep) {
const n = 24;
final step = sweep / n;
for (int i = 0; i <= n; i++) {
final a = start + step * i;
p.lineTo(cx + r * math.cos(a), cy + r * math.sin(a));
}
}
@override
bool shouldRepaint(PillBorderPainter old) => old.progress != progress || old.stroke != stroke;
}
class WavePainter extends CustomPainter {
final double t;
final Color color;
WavePainter({required this.t, required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final bw = size.width * 0.2;
final gap = size.width * 0.1;
final tw = 3 * bw + 2 * gap;
final sx = (size.width - tw) / 2;
for (int i = 0; i < 3; i++) {
final phase = t * 2 * math.pi + i * math.pi * 0.7;
final hr = 0.3 + 0.7 * ((math.sin(phase) + 1) / 2);
final bh = size.height * hr;
final x = sx + i * (bw + gap);
final y = (size.height - bh) / 2;
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, bw, bh), Radius.circular(bw / 2)),
paint,
);
}
}
@override
bool shouldRepaint(WavePainter old) => old.t != t;
}