fix(playback): 冷启动不再预加载音频,首次播放按保存进度打开

按今日日志定位:所谓"后台假死"其实是进程被系统回收
(13:14:46 ActivityManager: has died: prcp FGS,当时内存压力下批量杀进程)。
冷启动后旧代码会先联网 play→seek→pause,既慢又会和用户点播放打架。

- restorePlaybackState: 只恢复队列/进度/UI,不联网,不再调用 _loadRestoredPlayback
- 新增 playOrResume(): UI 按钮、通知栏、耳机键统一入口;
  冷启动首次播放走 _playCurrent(open + seek 到保存位置 + play)
- AudioPlayerHandler.play() 改走 playOrResume()
- 播放中每 10s 持久化一次进度,冷启动恢复更准确
- 窗口期收紧:网络轮询 10s→3s、切换去抖 3s→2s、
  起播看门狗 15s→10s、中段卡死 30s→20s

真机验证(release):
  冷启动 8s 内 play 调用 0 次(不再预加载)
  按键播放:冷启动首次播放 → open 21ms → restored position 15.751s → 1.5s 出声
This commit is contained in:
2026-10-24 20:04:42 +08:00
parent ff41769c56
commit 98db2321fb
3 changed files with 50 additions and 45 deletions
+2 -1
View File
@@ -246,7 +246,8 @@ class AudioPlayerHandler extends audio_service.BaseAudioHandler {
@override @override
Future<void> play() async { Future<void> play() async {
debugPrint('▶️ [handler] play() called'); debugPrint('▶️ [handler] play() called');
await _playback.resume(); // 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
await AudioService().playOrResume();
} }
@override @override
+47 -43
View File
@@ -87,8 +87,8 @@ class AudioService extends ChangeNotifier {
int _switchEpoch = 0; int _switchEpoch = 0;
/// 卡死判定阈值 / 节流 /// 卡死判定阈值 / 节流
static const Duration _stallThreshold = Duration(seconds: 30); static const Duration _stallThreshold = Duration(seconds: 20);
static const Duration _startStallThreshold = Duration(seconds: 15); static const Duration _startStallThreshold = Duration(seconds: 10);
DateTime? _lastStallRecoveryAt; DateTime? _lastStallRecoveryAt;
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置) /// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
@@ -115,6 +115,12 @@ class AudioService extends ChangeNotifier {
int _metadataRetryGeneration = -1; int _metadataRetryGeneration = -1;
bool _firstAudioReported = false; bool _firstAudioReported = false;
/// 冷启动恢复后尚未真正打开媒体:首次点击播放走 _playCurrent(含 seek)
bool _restoredPendingOpen = false;
/// 播放中定时持久化位置(冷启动能回到准确位置)
DateTime _lastPositionSaveAt = DateTime.now();
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。 /// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
String? _lastPlaybackError; String? _lastPlaybackError;
String? get lastPlaybackError => _lastPlaybackError; String? get lastPlaybackError => _lastPlaybackError;
@@ -299,6 +305,7 @@ class AudioService extends ChangeNotifier {
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。 // 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。 // 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch; final epoch = ++_switchEpoch;
_restoredPendingOpen = false;
_isChangingTrack = true; _isChangingTrack = true;
_handlingCompletion = false; _handlingCompletion = false;
_hasStartedCurrentPlayback = false; _hasStartedCurrentPlayback = false;
@@ -497,13 +504,18 @@ class AudioService extends ChangeNotifier {
if (pos != _lastObservedPosition) { if (pos != _lastObservedPosition) {
_lastObservedPosition = pos; _lastObservedPosition = pos;
_lastProgressAt = now; _lastProgressAt = now;
// 每 10s 持久化一次进度:进程被系统回收后,冷启动能回到准确位置
if (now.difference(_lastPositionSaveAt) >= const Duration(seconds: 10)) {
_lastPositionSaveAt = now;
savePlaybackState();
}
return; return;
} }
final player = PlaybackService().player; final player = PlaybackService().player;
// 起播看门狗:playing=true 但 position 一直是 0,且没有任何 buffering/error 事件。 // 起播看门狗:playing=true 但 position 一直是 0,且没有任何 buffering/error 事件。
// 这种情况 mpv 不会自己恢复,等 30s 的通用规则又太慢,单独用 15s 阈值。 // 这种情况 mpv 不会自己恢复,通用 20s 规则又太慢,单独用 10s 阈值。
final stuckAtStart = pos == Duration.zero && final stuckAtStart = pos == Duration.zero &&
_trackStartAt != null && _trackStartAt != null &&
now.difference(_trackStartAt!) >= _startStallThreshold && now.difference(_trackStartAt!) >= _startStallThreshold &&
@@ -576,7 +588,7 @@ class AudioService extends ChangeNotifier {
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。 /// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
void _onNetworkChanged() { void _onNetworkChanged() {
_networkDebounce?.cancel(); _networkDebounce?.cancel();
_networkDebounce = Timer(const Duration(seconds: 3), () { _networkDebounce = Timer(const Duration(seconds: 2), () {
if (_currentSong == null) return; if (_currentSong == null) return;
if (_isChangingTrack) return; if (_isChangingTrack) return;
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲'); debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
@@ -823,10 +835,29 @@ class AudioService extends ChangeNotifier {
if (_isPlaying) { if (_isPlaying) {
PlaybackService().pause(); PlaybackService().pause();
} else { savePlaybackState();
PlaybackService().resume(); return;
} }
// 保存状态(包括进度)
playOrResume();
}
/// 统一的"继续播放"入口:UI 按钮、通知栏、耳机键都走这里。
///
/// 冷启动恢复后音频尚未加载,此时 resume() 是在空播放器上调用,
/// 表现就是"点了没反应"。这种情况改走 _playCurrent(open + seek + play)。
Future<void> playOrResume() async {
if (_currentSong == null) return;
if (_isPlaying) return;
if (_restoredPendingOpen) {
_restoredPendingOpen = false;
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
await _playCurrent();
return;
}
await PlaybackService().resume();
savePlaybackState(); savePlaybackState();
} }
@@ -1159,8 +1190,13 @@ class AudioService extends ChangeNotifier {
debugPrint( debugPrint(
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}'); '♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
// ⭐ 关键:加载音频但不自动播放 // 冷启动只恢复 UI 状态,不联网预加载。
await _loadRestoredPlayback(); // 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
_restoredPendingOpen = true;
_isPlaying = false;
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
notifyListeners();
return true; return true;
} catch (e) { } catch (e) {
@@ -1169,40 +1205,8 @@ class AudioService extends ChangeNotifier {
} }
} }
/// ⭐ 加载恢复的播放状态(加载音频,不自动播放) // 说明:冷启动不再预加载音频(原 _loadRestoredPlayback)。
Future<void> _loadRestoredPlayback() async { // 恢复只做 UI 状态,首次播放时由 togglePlay → _playCurrent 完成 open + seek。
if (_queue.isEmpty || _currentIndex < 0) return;
final song = _queue[_currentIndex];
if (song.url == null || song.url!.isEmpty) return;
// 启动监听
_startListening();
// 加载音频(带 headers)
final headers = await _getAuthHeadersForUrl(song.url!);
await PlaybackService().play(song.url!, headers: headers);
// 等待播放器准备好
await _waitForPlaybackStarted();
// 如果有待恢复的进度,执行 seek
if (_pendingSeekPosition != null &&
_pendingSeekPosition!.inMilliseconds > 0) {
final pos = _pendingSeekPosition!;
_pendingSeekPosition = null;
await PlaybackService().seek(pos);
positionNotifier.value = pos;
debugPrint('🎯 [AudioService] restored position: $pos');
}
// ⭐ 加载完成后立即暂停(用户点击播放按钮后才继续)
await PlaybackService().pause();
_isPlaying = false;
_hasStartedCurrentPlayback = true;
notifyListeners();
debugPrint('🎵 [AudioService] restored playback loaded and paused');
}
@override @override
void dispose() { void dispose() {
+1 -1
View File
@@ -25,7 +25,7 @@ class NetworkWatcher {
/// 网络签名变化时回调(已在内部做过去抖) /// 网络签名变化时回调(已在内部做过去抖)
void Function()? onChanged; void Function()? onChanged;
void start({Duration interval = const Duration(seconds: 10)}) { void start({Duration interval = const Duration(seconds: 3)}) {
if (_timer != null) return; if (_timer != null) return;
_timer = Timer.periodic(interval, (_) => _tick()); _timer = Timer.periodic(interval, (_) => _tick());
_tick(); _tick();