feat(recite): 完成背诵模块基础框架与核心页面开发

本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
This commit is contained in:
崔宝新
2026-09-11 14:38:47 +08:00
parent c65df455f2
commit 27eba92315
17 changed files with 4367 additions and 36 deletions
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:new_learn/core/logger/app_talker.dart';
/// ============================================================
/// 【ReciteAudioManager】背诵模块单例音频管理
/// —— 对标 Vue utils/audioManager
///
/// 规则(与 Vue 完全一致):
/// 1. 全局同时只播放一个音频:新 play 会先 stop 旧的;
/// 2. hasActive 为 true 时页面层直接忽略点击;
/// 3. 播放结束/失败/取消都清空 playingToken(驱动 4 帧动画停在第 1 帧);
/// 4. 离开答题/小结页调 stopAll() 释放(对标 cleanupAllAudio)。
/// ============================================================
class ReciteAudioManager {
ReciteAudioManager._() {
_playerSub = _player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
playingToken.value = null;
currentUrl = null;
}
});
}
static final ReciteAudioManager instance = ReciteAudioManager._();
final AudioPlayer _player = AudioPlayer();
StreamSubscription<PlayerState>? _playerSub;
/// 当前播放令牌(自增),null = 无播放;4 帧按钮据此判断是否该动
final ValueNotifier<int?> playingToken = ValueNotifier<int?>(null);
/// 当前播放的音频 URL,null = 无播放
/// —— VoiceAnimButton 用它判断"当前播的是不是我的音频",
/// 比 token 归属更可靠(自动播放 / 手动播放 / 跨组件触发都能联动)。
String? currentUrl;
int _seq = 0;
bool get hasActive => playingToken.value != null;
/// 播放网络音频,返回本次播放令牌(失败返回 null)
Future<int?> play(String url) async {
if (url.isEmpty) return null;
final token = ++_seq;
try {
await _player.stop();
currentUrl = url;
playingToken.value = token;
await _player.setUrl(url);
await _player.play();
return token;
} catch (e) {
talker.error('背诵音频播放失败: $e');
if (playingToken.value == token) {
playingToken.value = null;
currentUrl = null;
}
return null;
}
}
/// 停止并清空播放态(页面 dispose 时必须调)
Future<void> stopAll() async {
await _player.stop();
playingToken.value = null;
currentUrl = null;
}
void dispose() {
_playerSub?.cancel();
_player.dispose();
}
}