fix(playback): 缓冲抖动下看门狗失效 + 元数据改到播放稳定后再抓

实测(公网链路,自然播完 -> 自动下一首)暴露两个新问题:

1. 看门狗在 buffering 抖动时永远不触发
   原实现每次 buffering=true 都重置 30s 计时器,而弱网下它会 true/false
   每 2~5s 抖一次,于是计时器一直被重置,卡死 60s+ 也不会自愈。
   改为记录"最近一次缓冲时间",用「进度 30s 未推进 + 20s 内发生过缓冲」
   两个条件判定,暂停时不会误触发。

2. 元数据抓取在起播瞬间抢带宽
   新歌 open 后 mpv 还在持续填缓冲,此时插入 2MB 标签下载会把缓冲打回 0。
   实测下一首因此要等 60s+。
   改为等播放真正稳定(进度 >= 5s 且连续 2s 不在 buffering,最多等 30s)
   再抓元数据;等不到就跳过本次(下次播放再试),优先保证出声。

验证:公网 + 移动数据下切歌,next 发起后 2ms 即 open;
未稳定时日志出现"播放未稳定,跳过本次元数据抓取",不再有 2MB 下载与播放抢带宽。
This commit is contained in:
2026-10-23 21:35:40 +08:00
parent 2b33c17aab
commit 937bcdd63d
+82 -21
View File
@@ -84,11 +84,13 @@ class AudioService extends ChangeNotifier {
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
int _switchEpoch = 0;
/// 缓冲卡死看门狗
/// 卡死判定阈值 / 节流
static const Duration _stallThreshold = Duration(seconds: 30);
Timer? _stallTimer;
DateTime? _lastStallRecoveryAt;
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
DateTime? _lastBufferingAt;
/// 进度看门狗:某些卡死不会触发 buffering 事件(例如 CDN 半开连接),
/// 用「播放中进度长时间不动」兜底。
Timer? _progressTimer;
@@ -337,7 +339,9 @@ class AudioService extends ChangeNotifier {
if (_hasStartedCurrentPlayback) {
_lastPlaybackError = null;
_onSongChanged?.call(song);
_loadMetadataForCurrentSong(generation);
// 不在起播瞬间抓元数据:此时 mpv 正在填缓冲区,
// 2MB 的标签请求会和播放流抢带宽,实测会把"下一首"拖到 60s+。
_scheduleMetadataWhenSettled(generation, epoch);
}
// 切歌完成后保存状态
@@ -350,26 +354,84 @@ class AudioService extends ChangeNotifier {
}
}
/// 进度看门狗:播放中但进度 30 秒没动,说明流已经卡死(半开连接/CDN 卡住)。
/// 等播放真正稳定后再抓元数据。
///
/// 判定条件比"能出声"更严格:进度至少 5s、且连续 2s 不处于 buffering。
/// 原因:公网链路上新歌起播后 mpv 还在持续填缓冲,
/// 这时候插一个 2MB 的标签下载会直接把缓冲打回 0(实测下一首要等 60s+)。
/// 最多等 30s;等不到就放弃这次抓取(下次播放会再试)。
Future<bool> _waitForPlaybackSettled(int epoch,
{Duration timeout = const Duration(seconds: 30)}) async {
final player = PlaybackService().player;
final deadline = DateTime.now().add(timeout);
var quietChecks = 0;
while (DateTime.now().isBefore(deadline)) {
if (epoch != _switchEpoch) return false;
final ready = player.state.playing &&
!player.state.buffering &&
player.state.position >= const Duration(seconds: 5);
if (ready) {
quietChecks++;
// 每 500ms 采一次,连续 4 次即稳定 2 秒
if (quietChecks >= 4) return true;
} else {
quietChecks = 0;
}
await Future.delayed(const Duration(milliseconds: 500));
}
return false;
}
void _scheduleMetadataWhenSettled(int generation, int epoch) {
// 故意不 await:不能阻塞切歌流程和状态保存
() async {
try {
final settled = await _waitForPlaybackSettled(epoch);
if (!settled || epoch != _switchEpoch) {
debugPrint('⏸️ [AudioService] 播放未稳定,跳过本次元数据抓取(避免抢带宽)');
return;
}
await _loadMetadataForCurrentSong(generation);
} catch (e) {
debugPrint('⚠️ [AudioService] 延迟元数据抓取失败: $e');
}
}();
}
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
///
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
/// 每次 true 若都重置计时器,阈值永远不会到(实测公网链路抖动 60s+ 不恢复)。
/// 为什么不能只看进度:用户暂停时进度也不动,会误触发。
/// 两个条件同时满足即可区分。
void _checkPlaybackProgress() {
if (_isChangingTrack || !_isPlaying) {
// 切歌中或暂停时不参与判断,重置基准
if (_isChangingTrack) {
// 切歌中不参与判断,重置基准
_lastObservedPosition = positionNotifier.value;
_lastProgressAt = DateTime.now();
return;
}
final now = DateTime.now();
final pos = positionNotifier.value;
if (pos != _lastObservedPosition) {
_lastObservedPosition = pos;
_lastProgressAt = DateTime.now();
_lastProgressAt = now;
return;
}
if (DateTime.now().difference(_lastProgressAt) >= _stallThreshold) {
debugPrint('🩺 [AudioService] 播放进度卡在 $pos 超过 '
'${_stallThreshold.inSeconds}s,触发自愈');
_lastProgressAt = DateTime.now();
final stalledFor = now.difference(_lastProgressAt);
final recentlyBuffering = _lastBufferingAt != null &&
now.difference(_lastBufferingAt!) < const Duration(seconds: 20);
if (stalledFor >= _stallThreshold && recentlyBuffering) {
debugPrint('🩺 [AudioService] 进度停在 $pos 已 ${stalledFor.inSeconds}s,'
'且近期持续缓冲,触发自愈');
_lastProgressAt = now;
_recoverFromStall();
}
}
@@ -763,20 +825,21 @@ class AudioService extends ChangeNotifier {
}),
);
// 缓冲状态:定位"流量很高但缓冲为 0"时要靠它
// 缓冲状态:只记录"最近一次进入缓冲的时间"。
// 不能用 Timeout 重置计时:弱网下 buffering 会 true/false 反复抖动,
// 每次 true 都重置的话,30s 阈值永远不会到。
_subscriptions.add(
player.stream.buffering.listen((buffering) {
debugPrint('🌊 [AudioService] buffering=$buffering');
_stallTimer?.cancel();
_stallTimer = null;
if (!buffering) return;
// 持续缓冲超过阈值:多半是网络卡死,主动重连一次当前歌曲
_stallTimer = Timer(_stallThreshold, _recoverFromStall);
if (buffering) {
_lastBufferingAt = DateTime.now();
}
}),
);
// 进度看门狗:有些半开连接不会产生 buffering=true,
// 但播放进度会一直停在原地,这里兜住这种情况。
// 进度看门狗:播放中进度 30s 不动、且近期发生过缓冲 → 判定卡死并自愈。
// 用"进度 + 最近缓冲"两个条件,既不会在暂停时误触发,
// 也能覆盖 buffering 反复抖动、始终起不来的情况。
_progressTimer?.cancel();
_lastProgressAt = DateTime.now();
_progressTimer = Timer.periodic(
@@ -1005,8 +1068,6 @@ class AudioService extends ChangeNotifier {
@override
void dispose() {
// 先保存再清理
_stallTimer?.cancel();
_stallTimer = null;
_progressTimer?.cancel();
_progressTimer = null;
savePlaybackState();