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