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() {
|
void _showReauthDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -773,7 +811,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
if (mounted) safeSetState(() {});
|
if (mounted) safeSetState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onTap: () => _playSong(song),
|
onTap: () => _playFavorites(index),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../metadata/metadata_service.dart';
|
|||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
import '../utils/song_key.dart';
|
import '../utils/song_key.dart';
|
||||||
|
import '../utils/position_guard.dart';
|
||||||
import '../repositories/playlist_repository.dart';
|
import '../repositories/playlist_repository.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import 'webdav_service.dart';
|
import 'webdav_service.dart';
|
||||||
@@ -103,6 +104,15 @@ class AudioService extends ChangeNotifier {
|
|||||||
Duration _lastObservedPosition = Duration.zero;
|
Duration _lastObservedPosition = Duration.zero;
|
||||||
DateTime _lastProgressAt = DateTime.now();
|
DateTime _lastProgressAt = DateTime.now();
|
||||||
|
|
||||||
|
/// 最后已知的有效播放位置。
|
||||||
|
///
|
||||||
|
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
|
||||||
|
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
|
||||||
|
Duration _lastKnownPosition = Duration.zero;
|
||||||
|
|
||||||
|
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
|
||||||
|
bool _isRecovering = false;
|
||||||
|
|
||||||
/// 当前歌曲开始加载的时间,用于"起播看门狗":
|
/// 当前歌曲开始加载的时间,用于"起播看门狗":
|
||||||
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
|
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
|
||||||
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||||
@@ -312,6 +322,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
_isChangingTrack = true;
|
_isChangingTrack = true;
|
||||||
_handlingCompletion = false;
|
_handlingCompletion = false;
|
||||||
_hasStartedCurrentPlayback = false;
|
_hasStartedCurrentPlayback = false;
|
||||||
|
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
|
||||||
|
_lastKnownPosition = Duration.zero;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_playbackGeneration++;
|
_playbackGeneration++;
|
||||||
@@ -556,10 +568,29 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_openRetryGeneration == _playbackGeneration) return;
|
if (_openRetryGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
_openRetryGeneration = _playbackGeneration;
|
_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 (_currentSong == null) return;
|
||||||
if (_isChangingTrack) return;
|
if (_isChangingTrack) return;
|
||||||
|
// mpv 可能自己接上了:位置在推进就不打断它
|
||||||
|
if (positionNotifier.value >
|
||||||
|
baseline + const Duration(seconds: 1)) {
|
||||||
|
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_recoverFromStall(force: true);
|
_recoverFromStall(force: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -584,8 +615,11 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_lastStallRecoveryAt = now;
|
_lastStallRecoveryAt = now;
|
||||||
|
|
||||||
final resumeAt = positionNotifier.value;
|
final resumeAt = _lastKnownPosition > Duration.zero
|
||||||
|
? _lastKnownPosition
|
||||||
|
: positionNotifier.value;
|
||||||
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
||||||
|
_isRecovering = true;
|
||||||
try {
|
try {
|
||||||
_trackStartAt = DateTime.now();
|
_trackStartAt = DateTime.now();
|
||||||
_playReadyAt = null;
|
_playReadyAt = null;
|
||||||
@@ -595,12 +629,15 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (resumeAt > Duration.zero) {
|
if (resumeAt > Duration.zero) {
|
||||||
await PlaybackService().seek(resumeAt);
|
await PlaybackService().seek(resumeAt);
|
||||||
positionNotifier.value = resumeAt;
|
positionNotifier.value = resumeAt;
|
||||||
|
_lastKnownPosition = resumeAt;
|
||||||
}
|
}
|
||||||
debugPrint('✅ [AudioService] 自动恢复完成');
|
debugPrint('✅ [AudioService] 自动恢复完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
||||||
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_isRecovering = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1009,27 @@ class AudioService extends ChangeNotifier {
|
|||||||
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
||||||
return;
|
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 的播放位置:这是"真正出声"的时刻,
|
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
||||||
// 与 play() 发起时间相减即可算出起播延迟。
|
// 与 play() 发起时间相减即可算出起播延迟。
|
||||||
if (!_firstAudioReported && position > Duration.zero) {
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// test/position_guard_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/utils/position_guard.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldIgnorePositionRegression', () {
|
||||||
|
test('正常播放推进:不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 78),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('77s 突然归零:拦截(这就是"从头重播"的元凶)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户切歌:允许回退', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: true,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自动恢复中:允许回退(我们自己重开的)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刚开始播放(最后位置 <10s):不拦截,避免误伤起播', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 8),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('小幅回退(77s -> 73s):不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 73),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user