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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user