Files
App.K12.Student.Miniapp_Flu…/lib/features/recite/widgets/voice_anim_button.dart
T
崔宝新 27eba92315 feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
2026-09-11 14:38:47 +08:00

136 lines
4.6 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.
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<VoiceAnimButton> createState() => _VoiceAnimButtonState();
}
class _VoiceAnimButtonState extends State<VoiceAnimButton> {
/// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列
static final List<String> _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<void> _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),
),
);
}
}