Skip to content

Commit 681481a

Browse files
committed
FIX: 修复复述后台录音取消与文件清理
1 parent 5d59a64 commit 681481a

38 files changed

Lines changed: 2282 additions & 299 deletions

File tree

‎TASKS.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
> 当前待办与本次完成记录;此前完成任务“随心听字幕列表按当前句定位和自动聚焦”(2026-09-29)已归档,详见 [2026-09-29 归档](./docs/tasks-archive/tasks-2026-09-29-completed.md)。
44
55
## 本次完成
6+
- [x] 修复复述后台取消转录处理中回合时遗留录音文件(2026-10-24):`RecordingService.cancelRecording` 新增显式 `discardStoppedRecording` 选项;复述后台收尾启用该选项,删除 `stopSession` 已结束但尚未完成转录的录音文件,同时默认取消路径仍保留已停止录音。新增 RecordingService 与复述控制器回归断言;先确认旧实现下控制器用例因删除列表为空而失败,修复后两个定向测试文件共 16 项通过。4 个相关 Dart 文件定向分析无问题,格式与 `git diff --check` 通过。测试输出 media_kit 原生依赖初始化告警,但用例全部通过。未运行 `scripts/check.sh`:改动局限于后台取消的临时录音清理,定向测试与分析覆盖受影响路径。**完成时间**: 2026-10-24 12:52
7+
- [x] 修复复述后台取消的处理中与启动竞态(2026-10-24):后台事件现在让当前录音回合代际失效,取消录音中、启动中及识别处理中底层会话;旧识别结果不能在状态重置后写回。保存后台收尾 future,新录音等待旧启动/识别收尾后再开始。新增录音中取消、处理中取消与丢弃迟到结果、取消收尾期间立即重试三项回归测试;旧实现下处理中状态迟到写回和立即重试均复现失败,修复后复述录音控制器与播放器测试共 40 项通过。4 个相关 Dart 文件定向分析无问题,格式与 `git diff --check` 通过。测试有 media_kit 原生依赖初始化告警,但用例全部通过。未运行 `scripts/check.sh`:改动范围局限于复述后台录音收尾,定向测试与分析覆盖相关路径。**完成时间**: 2026-10-24 12:45
8+
- [x] 修复复述以外的后台事件污染复述录音状态(2026-10-24):移除复述录音控制器的全局生命周期监听,改由仍活动的 `RetellPlayer` 在进入后台时显式通知录音控制器取消当前录音并设置等待开口超时标记;释放播放器后的生命周期事件仍由现有播放器守卫忽略。检查跟读与难句补练共用的 `SpeechRecordingController`:它保留自身后台取消录音并回到 idle 的行为,没有复述专用的粘性超时标记,因此未见同类状态泄漏。新增录音控制器与播放器生命周期回归断言。复述录音、复述播放器、跟读录音、跟读流程及难句补练测试共 120 项通过;4 个改动 Dart 文件定向分析无问题,格式与 `git diff --check` 通过。测试运行有 media_kit 原生依赖初始化告警,但用例全部通过。未运行 `scripts/check.sh`:改动限于复述生命周期所有权与状态隔离,定向测试覆盖受影响链路。**完成时间**: 2026-10-24 12:36
9+
- [x] 修复复述播放器退出后的后台生命周期状态污染(2026-10-24):`RetellPlayer` Provider 为 keep-alive,退出时播放驱动清空、状态重置,但生命周期监听仍存活;现仅在存在活动播放驱动时处理后台事件,避免释放后的默认 `listening` 状态被改为 `retelling`。新增释放后忽略后台事件及活动播放器仍正常暂停的回归测试。复述播放器 Provider 测试 28 项通过,2 个相关 Dart 文件定向分析无问题,`dart format` 与 `git diff --check` 通过。测试运行有 media_kit 原生依赖初始化告警,但用例全部通过。未运行 `scripts/check.sh`:改动局限于复述播放器生命周期入口,定向测试和分析覆盖本次范围。**完成时间**: 2026-10-24 12:22
10+
11+
- [x] 修复复述录音中点击主播放按钮未取消录音(2026-10-24):主播放按钮、视频播放控制及键盘播放快捷键统一经过页面处理器;录音中先阻止自动录音监听器重启,再取消并清理录音,随后按播放器阶段执行暂停/续播或重播。新增“听原文暂停态仍有录音”及“复述录音取消后不自动重启”两项页面回归测试。复述页面测试 25 项、定向静态分析通过,`git diff --check` 通过。未运行 `scripts/check.sh`:修复范围限定在复述页交互链路,定向测试和分析覆盖本次改动。常规 `flutter` 命令无法写入 Flutter SDK 的 engine stamp,测试通过直接调用本机 Flutter 工具快照完成。**完成时间**: 2026-10-24
12+
13+
- [x] 修复播放暂停检查点串用(2026-10-24):确认并修复难句补练切句/盲听切跟读后复用旧暂停位置,以及逐句精听重置后残留盲听断点。复核意群 UI 后确认页面仅支持点击单个意群后自动播放,`playAllSenseGroups` 没有页面调用入口;撤回基于该未接入方法推导的意群续播代码与测试,不将意群播放列为用户暂停验证场景。新增整句精确续播、切句、切模式和重置回归测试。逐句精听 Provider 58 项、难句补练 Provider 21 项、逐句精听页面 52 项测试通过;4 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。测试环境输出 media_kit 初始化告警及页面测试依赖告警,但测试全部通过。未运行 `scripts/check.sh`:改动局限于两个播放 Provider 的暂停状态与检查点清理,定向测试和分析覆盖受影响链路;未运行 Maestro:未改变布局、路由或首启流程。**完成时间**: 2026-10-24
14+
15+
- [x] 修复逐句精听讲解页播放按钮无响应(2026-10-23):暂停断点按盲听/讲解模式隔离;进入讲解页和切换句子时清理旧检查点,避免讲解页误将盲听断点当作可恢复位置而调用空操作。新增跨模式回归测试。播放器 Provider 测试 55 项、页面测试 52 项通过;3 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。未运行 `scripts/check.sh`:改动限定在精听播放状态恢复链路,定向检查覆盖受影响范围;未运行 Maestro:未改变布局、路由或首启流程。**完成时间**: 2026-10-23 23:29
16+
17+
- [x] 统一播放暂停与恢复位置(2026-10-23):随心听、逐句精听、盲听、难句补练和复述暂停时在内存保存当前媒体位置,恢复时从该位置继续;流程引擎为恢复提供独立入口,保留重复次数及 prompt 状态。段落播放恢复到暂停时间,句子自然结束后再次播放仍从句首开始;切句/切段会丢弃旧检查点。8 个 Provider/引擎测试文件共 201 项通过;2 个页面测试文件 93 项通过、5 项跳过;24 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。后续复审修复盲听与跟读流程在异步开播前检查期间切句时可能串用暂停位置的问题:恢复位置改为局部传入本次开播调用,盲听流程在异步检查后校验 flowToken;新增受控时序回归测试。两个流程引擎测试文件共 17 项通过,4 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。`scripts/check.sh` 已尝试,但全仓分析被既有集成测试类型错误阻断,未进入全量测试或 macOS 构建;本次修复未重跑:范围限于两个流程引擎,定向测试和分析覆盖修改。未运行 Maestro:未改变布局、路由或首启流程。**原完成时间**: 2026-10-23 19:57;**竞态修复完成时间**: 2026-10-23 20:21;复查用户截图后补齐句子详情页:暂停时快照区间播放器绝对位置并通过可选续播能力从该位置恢复,纯音频路径改为 pause 而非 stop;自然完成后检查点已消费,下次仍从句首播放,意群试听仍从区间起点播放。新增句子详情页暂停续播与自然结束重播回归测试。句子详情页与两个播放适配器测试目标共 38 项通过,7 个相关 Dart 文件定向分析无问题,git diff --check 通过。未运行 scripts/check.sh:变更限于本地播放恢复链路;未运行 Maestro:未改变布局、路由或首启流程。**详情页补齐完成时间**: 2026-10-23 20:33;难句跟读页复测发现其独立播放链路仍把暂停映射为 enterWaitingForUser、恢复映射为 replayCurrentSentence。现在控制器在显式暂停时快照当前位置,下一次播放走 RepeatFlowEngine.resumeCurrentSentence 与 SentencePlaybackDriver.startAt;无暂停位置仍按原行为从句首重播,并保留键盘快捷键“播完当前句再暂停”的语义。新增控制器暂停续播/自然结束重播回归测试和页面按钮分发测试。相关 Provider 与 Screen 测试目标共 58 项通过;4 个相关 Dart 文件定向分析无问题,git diff --check 通过。未运行 scripts/check.sh:改动局限于难句跟读暂停恢复链路,定向验证已覆盖;未运行 Maestro:未改布局、路由或首启流程。**难句跟读补齐完成时间**: 2026-10-23 23:05
618

719
- [x] ECH-26 AI 助教支持更多学习上下文(2026-10-23):句子聊天请求在当前句之外附带所在材料前 5 句和后 2 句,跨段落取句并保持字幕顺序;材料首尾按实际范围截取,空白句跳过,字幕缺失、索引无效或没有有效邻句时回退为单句请求。句子讲解、学习页入口及文本选区追问复用同一段落上下文;聊天会话身份包含段落,避免相同句子在不同材料间串用历史。按后端契约发送可选 `context.passage`。新增上下文截取、边界与请求载荷测试;3 个定向测试文件共 21 项通过,18 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。后续复审修复收藏模式、难句跟读和难句补练将筛选列表误用为完整字幕的问题:分页显示/练习队列仍按原逻辑过滤,邻句只从完整字幕序列选取;空白句会向两侧继续扫描,以有效句补足最多 5/2 句。日志复核进一步发现跟读会话的 `prepareSession` 曾用难句练习子集覆盖完整字幕;现已显式传入并保留完整上下文源。新增收藏分页器与难句补练入口回归覆盖;上下文单测 5 项、随心听页面 44 项、难句补练 Provider 18 项、跟读 Provider 34 项及难句补练入口 1 项通过,13 个相关 Dart 文件定向分析无问题,`git diff --check` 通过。未运行 `scripts/check.sh`:改动仅限句子上下文来源,定向验证覆盖相关链路;未运行 Maestro:未改变页面布局、路由或首启流程。**完成时间**: 2026-10-23 16:07
820

‎lib/models/sense_group_range_playback.dart‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,20 @@ abstract interface class SenseGroupRangePlayback {
99
/// 取消当前意群播放,并使迟到的异步回调失效。
1010
Future<void> cancel();
1111
}
12+
13+
/// 支持句子讲解页暂停续播的区间播放能力。
14+
///
15+
/// 意群试听仍使用 [SenseGroupRangePlayback.play] 从区间起点播放;只有句子讲解页
16+
/// 显式调用 [playFromPosition] 时才从已有暂停位置继续。
17+
abstract interface class ResumableSentenceRangePlayback {
18+
/// 当前媒体绝对位置。
19+
Duration get currentPosition;
20+
21+
/// 从 [position] 继续播放到 [end]。
22+
Future<void> playFromPosition(
23+
String audioItemId,
24+
Duration start,
25+
Duration end,
26+
Duration position,
27+
);
28+
}

‎lib/providers/audio_engine/audio_engine_sense_group_range_playback.dart‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import '../../models/sense_group_range_playback.dart';
22
import 'audio_engine_provider.dart';
33

44
/// 基于媒体 [AudioEngine] 的按需加载意群播放实现。
5-
class AudioEngineSenseGroupRangePlayback implements SenseGroupRangePlayback {
5+
class AudioEngineSenseGroupRangePlayback
6+
implements SenseGroupRangePlayback, ResumableSentenceRangePlayback {
67
AudioEngineSenseGroupRangePlayback({
78
required AudioEngine engine,
89
required double Function() playbackSpeed,
@@ -14,7 +15,29 @@ class AudioEngineSenseGroupRangePlayback implements SenseGroupRangePlayback {
1415
int _generation = 0;
1516

1617
@override
17-
Future<void> play(String audioItemId, Duration start, Duration end) async {
18+
Duration get currentPosition => _engine.absoluteCurrentPosition;
19+
20+
@override
21+
Future<void> play(String audioItemId, Duration start, Duration end) =>
22+
_playRange(audioItemId, start, end);
23+
24+
@override
25+
Future<void> playFromPosition(
26+
String audioItemId,
27+
Duration start,
28+
Duration end,
29+
Duration position,
30+
) => _playRange(
31+
audioItemId,
32+
position >= start && position < end ? position : start,
33+
end,
34+
);
35+
36+
Future<void> _playRange(
37+
String audioItemId,
38+
Duration start,
39+
Duration end,
40+
) async {
1841
final generation = ++_generation;
1942
await _engine.playRangeForAudio(
2043
audioItemId,

‎lib/providers/audio_engine/foreground_audio_engine_provider.dart‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -387,20 +387,40 @@ class ForegroundAudioEngine extends _$ForegroundAudioEngine {
387387
}
388388

389389
// --- 句子级播放基元 ---
390-
Future<void> playClipOnce(Sentence sentence, int sessionId) async {
390+
Future<void> playClipOnce(Sentence sentence, int sessionId) =>
391+
_playClipFromPosition(sentence, sessionId, sentence.startTime);
392+
393+
/// 从句子内部的位置播放,供暂停恢复使用。
394+
Future<void> playClipFromPosition(
395+
Sentence sentence,
396+
int sessionId,
397+
Duration startAt,
398+
) => _playClipFromPosition(sentence, sessionId, startAt);
399+
400+
Future<void> _playClipFromPosition(
401+
Sentence sentence,
402+
int sessionId,
403+
Duration requestedStart,
404+
) async {
391405
if (!isActiveSession(sessionId)) return;
392406

407+
final clipStart =
408+
requestedStart >= sentence.startTime &&
409+
requestedStart < sentence.endTime
410+
? requestedStart
411+
: sentence.startTime;
412+
393413
_wordHighlightCompletionSessionId = null;
394414
_wordHighlightObservedPlayingSessionId = null;
395415
_wordHighlightPlaybackStatus.prepareForPlayback();
396416

397417
AppLogger.log(
398418
'ForegroundAudioEngine',
399419
'▶ playClip: loadedAudio=${state.currentAudioId}, '
400-
'clip=${sentence.startTime.inMilliseconds}-${sentence.endTime.inMilliseconds}ms',
420+
'clip=${clipStart.inMilliseconds}-${sentence.endTime.inMilliseconds}ms',
401421
);
402-
state = state.copyWith(clipStart: sentence.startTime, isClipActive: true);
403-
await _player.setClip(start: sentence.startTime, end: sentence.endTime);
422+
state = state.copyWith(clipStart: clipStart, isClipActive: true);
423+
await _player.setClip(start: clipStart, end: sentence.endTime);
404424

405425
if (!isActiveSession(sessionId)) {
406426
AppLogger.log(

‎lib/providers/audio_engine/foreground_sense_group_range_playback.dart‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import 'foreground_audio_engine_provider.dart';
77
/// 区间播放前由 [ForegroundAudioEngine] 自动确认材料已加载;UI 不需要也不能
88
/// 预先管理播放器状态。generation 让取消/重复点击后的旧异步调用失效;session 所有权
99
/// 让迟到的取消只暂停本适配器仍在播放的区间,不会中断新句的主播放。
10-
class ForegroundSenseGroupRangePlayback implements SenseGroupRangePlayback {
10+
class ForegroundSenseGroupRangePlayback
11+
implements SenseGroupRangePlayback, ResumableSentenceRangePlayback {
1112
ForegroundSenseGroupRangePlayback({
1213
required ForegroundAudioEngine engine,
1314
required double Function() playbackSpeed,
@@ -20,7 +21,29 @@ class ForegroundSenseGroupRangePlayback implements SenseGroupRangePlayback {
2021
int? _activeSessionId;
2122

2223
@override
23-
Future<void> play(String audioItemId, Duration start, Duration end) async {
24+
Duration get currentPosition => _engine.absoluteCurrentPosition;
25+
26+
@override
27+
Future<void> play(String audioItemId, Duration start, Duration end) =>
28+
_playRange(audioItemId, start, end);
29+
30+
@override
31+
Future<void> playFromPosition(
32+
String audioItemId,
33+
Duration start,
34+
Duration end,
35+
Duration position,
36+
) => _playRange(
37+
audioItemId,
38+
position >= start && position < end ? position : start,
39+
end,
40+
);
41+
42+
Future<void> _playRange(
43+
String audioItemId,
44+
Duration start,
45+
Duration end,
46+
) async {
2447
final generation = ++_generation;
2548
int? startedSessionId;
2649
AppLogger.log(

‎lib/providers/blind_flow/blind_practice_flow_engine.dart‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,18 @@ class BlindPracticeFlowCallbacks {
5454
)
5555
playSentence;
5656

57+
/// 从暂停位置继续播放当前句;未提供时退回普通起播。
58+
final Future<SentencePlaybackResult> Function(
59+
Sentence sentence,
60+
int flowToken,
61+
Duration startAt,
62+
)?
63+
resumeSentence;
64+
5765
const BlindPracticeFlowCallbacks({
5866
required this.pauseAudio,
5967
required this.playSentence,
68+
this.resumeSentence,
6069
});
6170
}
6271

@@ -168,6 +177,20 @@ class BlindPracticeFlowEngine {
168177
await _playCurrentSentence();
169178
}
170179

180+
/// 从给定媒体位置继续当前句,保留本轮重复次数和流程状态。
181+
Future<void> resumeCurrentSentence(Duration startAt) async {
182+
if (_disposed || currentSentence == null) return;
183+
_waitAfterCurrentPrompt = false;
184+
_stopActiveResources();
185+
_updateState(
186+
_state.copyWith(
187+
phase: const BlindIdle(),
188+
flowToken: _state.flowToken + 1,
189+
),
190+
);
191+
await _playCurrentSentence(startAt: startAt);
192+
}
193+
171194
Future<void> nextSentence() async {
172195
if (_disposed) return;
173196
if (_state.isLastSentence) return;
@@ -258,10 +281,13 @@ class BlindPracticeFlowEngine {
258281
_sentences = [];
259282
}
260283

261-
Future<void> _playCurrentSentence() async {
284+
/// 播放当前句,并在异步前置检查后校验本次 flowToken。
285+
/// [startAt] 仅属于本次启动,避免切句后串用旧的恢复位置。
286+
Future<void> _playCurrentSentence({Duration? startAt}) async {
262287
if (_disposed) return;
263288
final sentence = currentSentence;
264289
if (sentence == null) return;
290+
final initialFlowToken = _state.flowToken;
265291

266292
if (sentence.duration <= Duration.zero) {
267293
await _advanceToNextSentence();
@@ -270,7 +296,7 @@ class BlindPracticeFlowEngine {
270296

271297
final canStart =
272298
await _config.onBeforeSentenceStart?.call(_state.sentenceIndex) ?? true;
273-
if (_disposed) return;
299+
if (_disposed || initialFlowToken != _state.flowToken) return;
274300
if (!canStart) {
275301
await _skipCurrentSentence(reason: 'before-sentence-start-rejected');
276302
return;
@@ -286,7 +312,10 @@ class BlindPracticeFlowEngine {
286312
'flowToken=$token',
287313
);
288314

289-
final result = await callbacks.playSentence(sentence, token);
315+
final resumeSentence = callbacks.resumeSentence;
316+
final result = startAt != null && resumeSentence != null
317+
? await resumeSentence(sentence, token, startAt)
318+
: await callbacks.playSentence(sentence, token);
290319
if (_disposed) return;
291320
final currentFlowToken = _state.flowToken;
292321
AppLogger.log(

0 commit comments

Comments
 (0)