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:
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user