本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
136 lines
4.6 KiB
Dart
136 lines
4.6 KiB
Dart
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),
|
||
),
|
||
);
|
||
}
|
||
}
|