fix(playback): 位置回退保护 + 恢复用最后已知位置 + 缓冲感知重试

对应 10/24 日志里的"播到一半中断后从头重播":
  23:05:26 ffurl_read -> 从 77s 重连成功
  23:05:33 再断 -> position 归 0 -> 看门狗"从 0s 继续",听感就是重新开始

- 新增 _lastKnownPosition:位置流累积,切歌归零;
  所有恢复路径(看门狗 / 网络切换 / 打开失败)都用它续播
- 位置回退保护(抽成 utils/position_guard.dart):
  已播 >10s 且新位置 <5s 时忽略回退,除非用户 seek / 切歌 / 正在恢复
- _scheduleOpenRetry 改为缓冲感知:
  缓冲余量 >=12s 时延迟 4s 重试(不打断),否则 1.2s;
  重试前若位置已推进超过 1s,判定 mpv 自愈,取消重连
- 收藏列表点击改为整队入队(setQueue,从点击项开始)

测试:新增 position_guard 6 个用例,累计 25 个全绿。
真机验证:收藏整队日志 "17 首,从第 16 首开始",播放正常。
This commit is contained in:
2026-10-31 21:22:56 +08:00
parent 672a545c38
commit ca4546871c
4 changed files with 199 additions and 4 deletions
+39 -1
View File
@@ -336,6 +336,44 @@ class _HomePageState extends BaseState<HomePage> {
}
}
/// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表
Future<void> _playFavorites(int index) async {
if (_favorites.isEmpty) return;
try {
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先登录 WebDAV'),
backgroundColor: Colors.orange,
),
);
}
return;
}
final songs = _favorites
.map((item) => Song(
id: item.path,
title: item.displayTitle,
artist: item.displaySubtitle,
url: WebDAVService.instance.getFileUrl(item.path),
))
.toList();
if (!mounted) return;
debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始');
await context.read<AudioService>().setQueue(songs, startIndex: index);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('播放失败: $e'), backgroundColor: Colors.red),
);
}
}
}
// ⭐ 退出/重新登录对话框
void _showReauthDialog() {
showDialog(
@@ -773,7 +811,7 @@ class _HomePageState extends BaseState<HomePage> {
if (mounted) safeSetState(() {});
},
),
onTap: () => _playSong(song),
onTap: () => _playFavorites(index),
),
),
);
+61 -3
View File
@@ -13,6 +13,7 @@ import '../metadata/metadata_service.dart';
import '../database/song_database.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
import '../utils/position_guard.dart';
import '../repositories/playlist_repository.dart';
import '../models/playlist.dart';
import 'webdav_service.dart';
@@ -103,6 +104,15 @@ class AudioService extends ChangeNotifier {
Duration _lastObservedPosition = Duration.zero;
DateTime _lastProgressAt = DateTime.now();
/// 最后已知的有效播放位置。
///
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
Duration _lastKnownPosition = Duration.zero;
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
bool _isRecovering = false;
/// 当前歌曲开始加载的时间,用于"起播看门狗":
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
@@ -312,6 +322,8 @@ class AudioService extends ChangeNotifier {
_isChangingTrack = true;
_handlingCompletion = false;
_hasStartedCurrentPlayback = false;
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
_lastKnownPosition = Duration.zero;
try {
_playbackGeneration++;
@@ -556,10 +568,29 @@ class AudioService extends ChangeNotifier {
if (_openRetryGeneration == _playbackGeneration) return;
_openRetryGeneration = _playbackGeneration;
debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试');
Timer(const Duration(milliseconds: 1500), () {
// 缓冲余量决定重试节奏:
// 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉;
// 余量见底 -> 尽快重连,尽量减少静音时间。
final bufferAhead = bufferedNotifier.value - positionNotifier.value;
final hasBuffer = bufferAhead >= const Duration(seconds: 12);
final delay = hasBuffer
? const Duration(seconds: 4)
: const Duration(milliseconds: 1200);
final baseline = _lastKnownPosition;
debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,'
'${delay.inMilliseconds}ms 后重试');
Timer(delay, () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
// mpv 可能自己接上了:位置在推进就不打断它
if (positionNotifier.value >
baseline + const Duration(seconds: 1)) {
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
return;
}
_recoverFromStall(force: true);
});
}
@@ -584,8 +615,11 @@ class AudioService extends ChangeNotifier {
}
_lastStallRecoveryAt = now;
final resumeAt = positionNotifier.value;
final resumeAt = _lastKnownPosition > Duration.zero
? _lastKnownPosition
: positionNotifier.value;
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
_isRecovering = true;
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
@@ -595,12 +629,15 @@ class AudioService extends ChangeNotifier {
if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt;
_lastKnownPosition = resumeAt;
}
debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners();
} finally {
_isRecovering = false;
}
}
@@ -972,6 +1009,27 @@ class AudioService extends ChangeNotifier {
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
return;
}
// 位置回退保护:打开失败/重连时 mpv 会短暂报 0。
// 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s,
// 否则界面进度会归零,恢复时也会"从头播"。
if (shouldIgnorePositionRegression(
lastKnown: _lastKnownPosition,
incoming: position,
isChangingTrack: _isChangingTrack,
isRecovering: _isRecovering,
)) {
debugPrint('🛡️ [AudioService] 忽略位置回退: '
'${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s');
return;
}
if (position > _lastKnownPosition ||
_isChangingTrack ||
_isRecovering) {
_lastKnownPosition = position;
}
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
// 与 play() 发起时间相减即可算出起播延迟。
if (!_firstAudioReported && position > Duration.zero) {
+20
View File
@@ -0,0 +1,20 @@
// lib/utils/position_guard.dart
/// 位置回退保护。
///
/// 打开失败 / 重连瞬间,mpv 会短暂把 position 报成 0 或几秒。
/// 如果直接采用,界面进度会归零,恢复时也会"从头播"。
///
/// 规则:只有在"已经播过一段时间"且"新位置几乎归零"时才算异常回退;
/// 用户 seek、切歌、我们自己的恢复流程都允许回退,不拦截。
bool shouldIgnorePositionRegression({
required Duration lastKnown,
required Duration incoming,
required bool isChangingTrack,
required bool isRecovering,
}) {
if (isChangingTrack || isRecovering) return false;
if (lastKnown <= const Duration(seconds: 10)) return false;
if (incoming >= const Duration(seconds: 5)) return false;
return (lastKnown - incoming) > const Duration(seconds: 5);
}