From 937bcdd63d2e3dc29fa4298325a7d474d45233a1 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Fri, 23 Oct 2026 21:35:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(playback):=20=E7=BC=93=E5=86=B2=E6=8A=96?= =?UTF-8?q?=E5=8A=A8=E4=B8=8B=E7=9C=8B=E9=97=A8=E7=8B=97=E5=A4=B1=E6=95=88?= =?UTF-8?q?=20+=20=E5=85=83=E6=95=B0=E6=8D=AE=E6=94=B9=E5=88=B0=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=A8=B3=E5=AE=9A=E5=90=8E=E5=86=8D=E6=8A=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测(公网链路,自然播完 -> 自动下一首)暴露两个新问题: 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 下载与播放抢带宽。 --- lib/services/audio_service.dart | 103 +++++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 9cd2465..d8b46d5 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -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 _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();