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();