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:
@@ -123,6 +123,17 @@ class AudioService extends ChangeNotifier {
|
||||
DateTime? _lastPlayRequestAt;
|
||||
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、
|
||||
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||
@@ -347,6 +358,8 @@ class AudioService extends ChangeNotifier {
|
||||
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
|
||||
_lastKnownPosition = Duration.zero;
|
||||
_recoveryAttempts = 0;
|
||||
_nextRecoveryAt = null;
|
||||
_autoRetryTimer?.cancel();
|
||||
|
||||
try {
|
||||
_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 回原位置。
|
||||
///
|
||||
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
|
||||
@@ -629,13 +655,26 @@ class AudioService extends ChangeNotifier {
|
||||
final url = song.url;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
// 熔断:同一首歌连续恢复失败,说明不是"卡了一下",
|
||||
// 而是这个文件/连接根本拿不到数据。继续重试只会刷日志、抢带宽。
|
||||
// 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。
|
||||
// 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。
|
||||
if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_recoveryAttempts >= _maxRecoveryAttempts) {
|
||||
debugPrint('⛔ [AudioService] 已连续恢复 $_recoveryAttempts 次仍无法播放,'
|
||||
'停止自动重试:${song.title}');
|
||||
_lastPlaybackError = '无法播放:${song.title}';
|
||||
notifyListeners();
|
||||
final delay = _backoffFor(_recoveryAttempts);
|
||||
_nextRecoveryAt = DateTime.now().add(delay);
|
||||
debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,'
|
||||
'${delay.inSeconds}s 后再试:${song.title}');
|
||||
if (_recoveryAttempts >= _maxRecoveryAttempts + 3) {
|
||||
// 退避到极限:停住,交给用户手动重试(点播放会清零计数)
|
||||
debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}');
|
||||
_lastPlaybackError = '网络拥塞,无法播放:${song.title}';
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_scheduleAutoRetry(delay);
|
||||
_recoveryAttempts++;
|
||||
return;
|
||||
}
|
||||
_recoveryAttempts++;
|
||||
@@ -959,6 +998,10 @@ class AudioService extends ChangeNotifier {
|
||||
if (_currentSong == null) return;
|
||||
if (_isPlaying) return;
|
||||
|
||||
// 用户手动点播放:重置退避计数,允许立即重试
|
||||
_recoveryAttempts = 0;
|
||||
_nextRecoveryAt = null;
|
||||
|
||||
if (_restoredPendingOpen) {
|
||||
_restoredPendingOpen = false;
|
||||
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
|
||||
@@ -1083,6 +1126,8 @@ class AudioService extends ChangeNotifier {
|
||||
// 真的出声了(>1s),说明恢复成功,重置熔断计数
|
||||
if (position > const Duration(seconds: 1)) {
|
||||
_recoveryAttempts = 0;
|
||||
_nextRecoveryAt = null;
|
||||
_autoRetryTimer?.cancel();
|
||||
}
|
||||
|
||||
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
||||
@@ -1356,6 +1401,8 @@ class AudioService extends ChangeNotifier {
|
||||
_progressTimer = null;
|
||||
_networkDebounce?.cancel();
|
||||
_networkDebounce = null;
|
||||
_autoRetryTimer?.cancel();
|
||||
_autoRetryTimer = null;
|
||||
NetworkWatcher().stop();
|
||||
savePlaybackState();
|
||||
_stopListening();
|
||||
|
||||
@@ -41,6 +41,20 @@ class PlaybackService {
|
||||
return;
|
||||
}
|
||||
_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;
|
||||
debugPrint(
|
||||
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
||||
|
||||
Reference in New Issue
Block a user