fix(playback): 自动恢复熔断,避免坏文件导致无限重试

日志证据(21:32~21:33):同一首 "Communications - Keeno"
  Failed to open ×42,自动恢复 ×54 —— 看门狗每 10s 重开一次,死循环。

- 新增 _recoveryAttempts:同一首歌最多自动恢复 3 次
- 起播看门狗、网络切换、打开失败重试都受熔断约束
- 位置 >1s(真的出声)后清零,正常歌曲不受影响
- 切歌清零,用户手动点播放可重新尝试
- 熔断后写入 lastPlaybackError="无法播放:<title>" 并停住,不再刷请求

测试 25 个全绿;release 已安装。
This commit is contained in:
2026-10-31 21:41:37 +08:00
parent ca4546871c
commit fdca721250
+23
View File
@@ -113,6 +113,12 @@ class AudioService extends ChangeNotifier {
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的) /// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
bool _isRecovering = false; bool _isRecovering = false;
/// 当前这首歌已经连续自动恢复了几次(成功后清零)。
/// 打开一首本身拿不到数据的歌时,看门狗会无限重试,
/// 必须熔断,否则日志和请求都会爆炸。
int _recoveryAttempts = 0;
static const int _maxRecoveryAttempts = 3;
/// 当前歌曲开始加载的时间,用于"起播看门狗": /// 当前歌曲开始加载的时间,用于"起播看门狗":
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、 /// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。 /// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
@@ -324,6 +330,7 @@ class AudioService extends ChangeNotifier {
_hasStartedCurrentPlayback = false; _hasStartedCurrentPlayback = false;
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置" // 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
_lastKnownPosition = Duration.zero; _lastKnownPosition = Duration.zero;
_recoveryAttempts = 0;
try { try {
_playbackGeneration++; _playbackGeneration++;
@@ -606,6 +613,17 @@ 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 (_recoveryAttempts >= _maxRecoveryAttempts) {
debugPrint('⛔ [AudioService] 已连续恢复 $_recoveryAttempts 次仍无法播放,'
'停止自动重试:${song.title}');
_lastPlaybackError = '无法播放:${song.title}';
notifyListeners();
return;
}
_recoveryAttempts++;
final now = DateTime.now(); final now = DateTime.now();
if (!force && if (!force &&
_lastStallRecoveryAt != null && _lastStallRecoveryAt != null &&
@@ -1030,6 +1048,11 @@ class AudioService extends ChangeNotifier {
_lastKnownPosition = position; _lastKnownPosition = position;
} }
// 真的出声了(>1s),说明恢复成功,重置熔断计数
if (position > const Duration(seconds: 1)) {
_recoveryAttempts = 0;
}
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻, // 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
// 与 play() 发起时间相减即可算出起播延迟。 // 与 play() 发起时间相减即可算出起播延迟。
if (!_firstAudioReported && position > Duration.zero) { if (!_firstAudioReported && position > Duration.zero) {