import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/recite/widgets/recite_audio.dart'; /// ============================================================ /// 【VoiceAnimButton】发音 4 帧动画按钮 /// —— 1:1 对标 Vue components/FrameAnimation(:fps="5" :loop="true") /// /// 帧图内容(绿色从无到有递进): /// _frames[0] = icon_voice_2.png 全灰(0% 绿) /// _frames[1] = icon_voice_3.png 仅小圆点绿(~15%) /// _frames[2] = icon_voice_4.png 圆点+内弧绿(~55%) /// _frames[3] = icon_voice_1.png 全绿(100%)—— 静止帧 /// /// 行为: /// - 未播放时显示全绿静止帧(icon_voice_1.png); /// - 只要 ReciteAudioManager 正在播的是当前按钮的 url,就播动画 /// (自动播放 / 手动点击 / 跨组件触发都能联动,不依赖谁调用了 play); /// - 动画从全灰帧起步,5fps(200ms/帧)循环: /// 灰 → 少绿 → 中绿 → 全绿 → 灰 → ... /// - 点击时若无播放,则触发播放;已有播放则忽略; /// - 播放结束 / 失败 / 被顶掉 → 回到全绿静止帧。 /// ============================================================ class VoiceAnimButton extends StatefulWidget { const VoiceAnimButton({super.key, required this.url, this.size = 27}); /// 网络音频地址(空串时点击无效) final String url; /// 按钮边长(答题页 27px、小结页 18px) final double size; @override State createState() => _VoiceAnimButtonState(); } class _VoiceAnimButtonState extends State { /// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列 static final List _frames = [ ImgUrl.build('/StaticResource/2026-04-02/icon_voice_2.png'), // 全灰 ImgUrl.build('/StaticResource/2026-04-02/icon_voice_3.png'), // 仅圆点绿 ImgUrl.build('/StaticResource/2026-04-02/icon_voice_4.png'), // 圆点+内弧绿 ImgUrl.build('/StaticResource/2026-04-02/icon_voice_1.png'), // 全绿 ]; /// 静止帧索引(全绿 = icon_voice_1.png) static const int _restIndex = 3; /// 5fps = 200ms 切一帧 static const Duration _frameInterval = Duration(milliseconds: 200); Timer? _timer; int _frameIndex = _restIndex; @override void initState() { super.initState(); // 全局播放 token 变化 → 同步本按钮动画启停 // (用 token 作为"播放状态变化"的信号,用 currentUrl 判断是不是我的音频) ReciteAudioManager.instance.playingToken.addListener(_onPlayingChanged); // 初始化时如果正好在播我的 url,直接启动画 if (_isMinePlaying) _startAnim(); } /// 当前播的是不是我的音频 bool get _isMinePlaying { final mgr = ReciteAudioManager.instance; return mgr.playingToken.value != null && mgr.currentUrl == widget.url && widget.url.isNotEmpty; } void _onPlayingChanged() { if (_isMinePlaying) { _startAnim(); } else { _stopAnim(); } } void _startAnim() { if (_timer?.isActive ?? false) return; _frameIndex = 0; // 全灰起步 if (mounted) setState(() {}); _timer = Timer.periodic(_frameInterval, (_) { if (!mounted) return; setState(() => _frameIndex = (_frameIndex + 1) % 4); }); } void _stopAnim() { if (_timer == null) return; _timer?.cancel(); _timer = null; _frameIndex = _restIndex; if (mounted) setState(() {}); } Future _onTap() async { // 对标 Vue:if (audioManager.hasActiveAudio()) return; if (ReciteAudioManager.instance.hasActive) return; if (widget.url.isEmpty) return; await ReciteAudioManager.instance.play(widget.url); // 动画启停由 _onPlayingChanged 统一处理 } @override void dispose() { _timer?.cancel(); ReciteAudioManager.instance.playingToken.removeListener(_onPlayingChanged); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: _onTap, child: CachedNetworkImage( imageUrl: _frames[_frameIndex], width: widget.size.w, height: widget.size.w, fit: BoxFit.contain, placeholder: (_, _) => SizedBox(width: widget.size.w, height: widget.size.w), errorWidget: (_, _, _) => SizedBox(width: widget.size.w, height: widget.size.w), ), ); } }