fix(playback): 修复切歌永久假死,并让元数据抓取不再抢播放带宽
问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启):
1. _isChangingTrack 是没有超时的锁:_playCurrent() 置 true 后,
只有走完 finally 才会清。mpv 的 open() 在弱网下可能挂很久,
锁就一直不释放,之后 next()/previous() 全被静默拒绝。
2. 元数据用 dio.download 整文件下载,和 mpv 流式播放抢带宽,
20Mbps FRP 下播放流只剩一两百 K/s,缓冲长期为 0。
3. 没有订阅 mpv 的 error/buffering,失败无感知、无法自愈。
改动:
- playback_service: open 20s / 控制命令 5s 超时;失败时 stop 回空闲并上抛异常
- audio_service:
* 去掉「正在切歌就拒绝」的逻辑,改为 latest-wins(epoch 校验,
旧请求自行放弃,只有最新请求解锁)
* 播放失败不再吞掉:记录 lastPlaybackError 并 notifyListeners
* 订阅 stream.error / stream.buffering
* 缓冲超过 30s 自动重连当前歌曲并 seek 回原位置(2 分钟内最多一次)
- file_provider: 元数据改为 Range 只取头部 2MB,流式写入并在切歌时取消上一请求
- home_page: 去掉重复的 PlaybackService().play()(同一首歌会被 open 两次),
登录校验提前
验证:dart analyze 无 error;gradlew assembleDebug BUILD SUCCESSFUL。
真机验证待设备重新连接后进行。
This commit is contained in:
+104
-20
@@ -81,6 +81,18 @@ class AudioService extends ChangeNotifier {
|
||||
bool _hasStartedCurrentPlayback = false;
|
||||
bool _isUserSeeking = false;
|
||||
|
||||
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
|
||||
int _switchEpoch = 0;
|
||||
|
||||
/// 缓冲卡死看门狗
|
||||
static const Duration _stallThreshold = Duration(seconds: 30);
|
||||
Timer? _stallTimer;
|
||||
DateTime? _lastStallRecoveryAt;
|
||||
|
||||
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
|
||||
String? _lastPlaybackError;
|
||||
String? get lastPlaybackError => _lastPlaybackError;
|
||||
|
||||
int _playbackGeneration = 0;
|
||||
|
||||
// ---- 待恢复的播放进度 ----
|
||||
@@ -253,16 +265,14 @@ class AudioService extends ChangeNotifier {
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _playCurrent() async {
|
||||
if (_isChangingTrack) {
|
||||
debugPrint('⚠️ [AudioService] track switch already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
|
||||
stopPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
||||
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
||||
final epoch = ++_switchEpoch;
|
||||
_isChangingTrack = true;
|
||||
_handlingCompletion = false;
|
||||
_hasStartedCurrentPlayback = false;
|
||||
@@ -283,9 +293,29 @@ class AudioService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
await _playWithHeaders(song.url!);
|
||||
try {
|
||||
await _playWithHeaders(song.url!);
|
||||
} catch (e) {
|
||||
// 播放失败(超时/401/网络中断):必须走到 finally 解锁,
|
||||
// 并把错误记下来,供上层提示或下一首重试。
|
||||
_lastPlaybackError = '播放失败: $e';
|
||||
debugPrint('❌ [AudioService] play failed: $e');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (epoch != _switchEpoch) {
|
||||
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
|
||||
return;
|
||||
}
|
||||
|
||||
await _waitForPlaybackStarted();
|
||||
|
||||
if (epoch != _switchEpoch) {
|
||||
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
|
||||
return;
|
||||
}
|
||||
|
||||
// ⭐ 如果有待恢复的进度,执行 seek
|
||||
if (_pendingSeekPosition != null &&
|
||||
_pendingSeekPosition!.inMilliseconds > 0) {
|
||||
@@ -299,6 +329,7 @@ class AudioService extends ChangeNotifier {
|
||||
_syncPlayerStateDelayed();
|
||||
|
||||
if (_hasStartedCurrentPlayback) {
|
||||
_lastPlaybackError = null;
|
||||
_onSongChanged?.call(song);
|
||||
_loadMetadataForCurrentSong(generation);
|
||||
}
|
||||
@@ -306,12 +337,51 @@ class AudioService extends ChangeNotifier {
|
||||
// 切歌完成后保存状态
|
||||
await savePlaybackState();
|
||||
} finally {
|
||||
_isChangingTrack = false;
|
||||
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
|
||||
if (epoch == _switchEpoch) {
|
||||
_isChangingTrack = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackStarted() async {
|
||||
final player = PlaybackService().player;
|
||||
/// 缓冲卡死自愈:重开当前歌曲并 seek 回原位置。
|
||||
///
|
||||
/// 触发条件:mpv 的 buffering 持续为 true 超过 [_stallThreshold]。
|
||||
/// 两次自动恢复之间至少间隔 2 分钟,避免源站长时间不可用时反复重连。
|
||||
Future<void> _recoverFromStall() async {
|
||||
if (_isChangingTrack) return;
|
||||
|
||||
final song = _currentSong;
|
||||
if (song == null) return;
|
||||
final url = song.url;
|
||||
if (url == null || url.isEmpty) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
if (_lastStallRecoveryAt != null &&
|
||||
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
|
||||
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
|
||||
return;
|
||||
}
|
||||
_lastStallRecoveryAt = now;
|
||||
|
||||
final resumeAt = positionNotifier.value;
|
||||
debugPrint(
|
||||
'🩺 [AudioService] 缓冲超过 ${_stallThreshold.inSeconds}s,尝试重连:${song.title}');
|
||||
try {
|
||||
await _playWithHeaders(url);
|
||||
if (resumeAt > Duration.zero) {
|
||||
await PlaybackService().seek(resumeAt);
|
||||
positionNotifier.value = resumeAt;
|
||||
}
|
||||
debugPrint('✅ [AudioService] 自动恢复完成');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
||||
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackStarted() async { final player = PlaybackService().player;
|
||||
|
||||
if (player.state.playing) {
|
||||
_hasStartedCurrentPlayback = true;
|
||||
@@ -493,11 +563,7 @@ class AudioService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isChangingTrack) {
|
||||
debugPrint('⚠️ [next] REJECTED: track switch in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
|
||||
if (_playMode == PlayMode.shuffle) {
|
||||
if (_shuffledIndices.isEmpty) return;
|
||||
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||
@@ -515,12 +581,7 @@ class AudioService extends ChangeNotifier {
|
||||
Future<void> previous() async {
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
if (_isChangingTrack) {
|
||||
debugPrint(
|
||||
'⚠️ [AudioService] previous() ignored: track switch in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// 同 next():最新请求胜出,不拒绝
|
||||
if (_playMode == PlayMode.shuffle) {
|
||||
if (_shuffledIndices.isEmpty) return;
|
||||
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
|
||||
@@ -664,6 +725,27 @@ class AudioService extends ChangeNotifier {
|
||||
}),
|
||||
);
|
||||
|
||||
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
|
||||
_subscriptions.add(
|
||||
player.stream.error.listen((error) {
|
||||
debugPrint('❌ [AudioService] player error: $error');
|
||||
_lastPlaybackError = error;
|
||||
notifyListeners();
|
||||
}),
|
||||
);
|
||||
|
||||
// 缓冲状态:定位"流量很高但缓冲为 0"时要靠它
|
||||
_subscriptions.add(
|
||||
player.stream.buffering.listen((buffering) {
|
||||
debugPrint('🌊 [AudioService] buffering=$buffering');
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
if (!buffering) return;
|
||||
// 持续缓冲超过阈值:多半是网络卡死,主动重连一次当前歌曲
|
||||
_stallTimer = Timer(_stallThreshold, _recoverFromStall);
|
||||
}),
|
||||
);
|
||||
|
||||
// ⭐ 完整生命周期校验的 completed 监听
|
||||
_subscriptions.add(
|
||||
player.stream.completed.listen((_) {
|
||||
@@ -885,6 +967,8 @@ class AudioService extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
// 先保存再清理
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
savePlaybackState();
|
||||
_stopListening();
|
||||
positionNotifier.dispose();
|
||||
|
||||
Reference in New Issue
Block a user