feat(playback): 拥塞网络退避重试 + mpv 缓冲加大

针对公司共享出口(整栋楼抢网):
- 恢复退避:连续失败 3 次后 30s、4 次 60s、5 次 120s,
  冷却结束自动再试一次;累计 6 次后停止,等用户手动点
- 用户手动点播放会清零退避计数
- 成功出声(position>1s)后清零
- mpv 缓冲加大:cache-secs=60s、demuxer-max-bytes=64MiB、
  demuxer-readahead-secs=30

真机验证:缓冲参数设置成功,起播 1576ms(此前 2098ms)。
This commit is contained in:
2026-10-31 22:03:09 +08:00
parent 5c4b0c2728
commit 30e68780f4
2 changed files with 67 additions and 6 deletions
+53 -6
View File
@@ -123,6 +123,17 @@ class AudioService extends ChangeNotifier {
DateTime? _lastPlayRequestAt; DateTime? _lastPlayRequestAt;
int _lastPlayRequestIndex = -1; int _lastPlayRequestIndex = -1;
/// 恢复退避:拥塞网络里连续失败后拉长间隔,避免和整栋楼抢带宽。
DateTime? _nextRecoveryAt;
Timer? _autoRetryTimer;
static Duration _backoffFor(int attempts) {
if (attempts <= 2) return Duration.zero;
if (attempts == 3) return const Duration(seconds: 30);
if (attempts == 4) return const Duration(seconds: 60);
return const Duration(seconds: 120);
}
/// 当前歌曲开始加载的时间,用于"起播看门狗": /// 当前歌曲开始加载的时间,用于"起播看门狗":
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、 /// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。 /// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
@@ -347,6 +358,8 @@ class AudioService extends ChangeNotifier {
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置" // 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
_lastKnownPosition = Duration.zero; _lastKnownPosition = Duration.zero;
_recoveryAttempts = 0; _recoveryAttempts = 0;
_nextRecoveryAt = null;
_autoRetryTimer?.cancel();
try { try {
_playbackGeneration++; _playbackGeneration++;
@@ -618,6 +631,19 @@ class AudioService extends ChangeNotifier {
}); });
} }
/// 冷却结束后的自动重试(只试一次,成功与否交给下一轮判断)
void _scheduleAutoRetry(Duration delay) {
_autoRetryTimer?.cancel();
_autoRetryTimer = Timer(delay, () {
_nextRecoveryAt = null;
if (_currentSong == null) return;
if (_isChangingTrack) return;
if (positionNotifier.value > const Duration(seconds: 1)) return;
debugPrint('⏰ [AudioService] 冷却结束,再试一次');
_recoverFromStall(force: true);
});
}
/// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。 /// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。
/// ///
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。 /// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
@@ -629,13 +655,26 @@ class AudioService extends ChangeNotifier {
final url = song.url; final url = song.url;
if (url == null || url.isEmpty) return; if (url == null || url.isEmpty) return;
// 熔断:同一首歌连续恢复失败,说明不是"卡了一下", // 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。
// 而是这个文件/连接根本拿不到数据。继续重试只会刷日志、抢带宽。 // 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。
if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) {
return;
}
if (_recoveryAttempts >= _maxRecoveryAttempts) { if (_recoveryAttempts >= _maxRecoveryAttempts) {
debugPrint('⛔ [AudioService] 已连续恢复 $_recoveryAttempts 次仍无法播放,' final delay = _backoffFor(_recoveryAttempts);
'停止自动重试:${song.title}'); _nextRecoveryAt = DateTime.now().add(delay);
_lastPlaybackError = '无法播放:${song.title}'; debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,'
notifyListeners(); '${delay.inSeconds}s 后再试:${song.title}');
if (_recoveryAttempts >= _maxRecoveryAttempts + 3) {
// 退避到极限:停住,交给用户手动重试(点播放会清零计数)
debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}');
_lastPlaybackError = '网络拥塞,无法播放:${song.title}';
notifyListeners();
return;
}
_scheduleAutoRetry(delay);
_recoveryAttempts++;
return; return;
} }
_recoveryAttempts++; _recoveryAttempts++;
@@ -959,6 +998,10 @@ class AudioService extends ChangeNotifier {
if (_currentSong == null) return; if (_currentSong == null) return;
if (_isPlaying) return; if (_isPlaying) return;
// 用户手动点播放:重置退避计数,允许立即重试
_recoveryAttempts = 0;
_nextRecoveryAt = null;
if (_restoredPendingOpen) { if (_restoredPendingOpen) {
_restoredPendingOpen = false; _restoredPendingOpen = false;
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开'); debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
@@ -1083,6 +1126,8 @@ class AudioService extends ChangeNotifier {
// 真的出声了(>1s),说明恢复成功,重置熔断计数 // 真的出声了(>1s),说明恢复成功,重置熔断计数
if (position > const Duration(seconds: 1)) { if (position > const Duration(seconds: 1)) {
_recoveryAttempts = 0; _recoveryAttempts = 0;
_nextRecoveryAt = null;
_autoRetryTimer?.cancel();
} }
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻, // 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
@@ -1356,6 +1401,8 @@ class AudioService extends ChangeNotifier {
_progressTimer = null; _progressTimer = null;
_networkDebounce?.cancel(); _networkDebounce?.cancel();
_networkDebounce = null; _networkDebounce = null;
_autoRetryTimer?.cancel();
_autoRetryTimer = null;
NetworkWatcher().stop(); NetworkWatcher().stop();
savePlaybackState(); savePlaybackState();
_stopListening(); _stopListening();
+14
View File
@@ -41,6 +41,20 @@ class PlaybackService {
return; return;
} }
_player = Player(); _player = Player();
// 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
// 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
// 通过 platform 动态调用,避免依赖 media_kit 的内部类型。
try {
final platform = _player!.platform as dynamic;
platform.setProperty('cache-secs', '60');
platform.setProperty('demuxer-max-bytes', '64MiB');
platform.setProperty('demuxer-readahead-secs', '30');
debugPrint('✅ [PlaybackService] mpv 缓冲参数已设置(cache 60s / 64MiB)');
} catch (e) {
debugPrint('⚠️ [PlaybackService] 设置 mpv 缓冲参数失败: $e');
}
_initialized = true; _initialized = true;
debugPrint( debugPrint(
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}'); '✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');