- 接入豆包TTS V1 WebSocket API,支持故事朗读语音合成 - 新增 PillProgressButton 组件(药丸形进度按钮) - 新增 TTSService 单例,后台生成不中断 - 音频保存到 Capybara audio/ 目录 - 唱片架当前播放歌曲高亮(金色卡片+音波动效+喇叭图标) - 播放时气泡持续显示当前歌名,暂停后隐藏 - 音乐总监Prompt去固定模板,歌名不再重复 - 新增 API 参考文档(豆包语音合成) Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
9.5 KiB
Dart
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;
|
|
} |