Files
QingTing-Player/lib/services/audio_player_handler.dart
T
lxh2875931338 98db2321fb 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 出声
2026-10-24 20:04:42 +08:00

319 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/services/audio_player_handler.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:audio_service/audio_service.dart' as audio_service;
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'playback_service.dart';
import 'audio_service.dart';
import '../utils/file_provider_utils.dart';
/// 音频服务处理器:连接 media_kit 播放引擎与 Android 系统媒体会话
/// 职责:发布播放状态到系统通知栏,接收系统媒体按钮事件
/// 它不拥有 Player 实例,所有播放控制均委托给 PlaybackService
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
final PlaybackService _playback = PlaybackService();
// 当前媒体信息
String? _currentId;
String? _currentTitle;
String? _currentArtist;
Duration _currentPosition = Duration.zero;
DateTime _lastPublishTime = DateTime.now();
static const Duration _publishInterval = Duration(milliseconds: 500);
String? _currentArtworkPath;
bool _isPublishing = false;
AudioPlayerHandler() {
_bindToPlayer();
}
// ═══════════════════════════════════════════════════════════════
// 绑定播放器状态流
// ═══════════════════════════════════════════════════════════════
void _bindToPlayer() {
final player = _playback.player;
player.stream.playing.listen((playing) {
_publishPlaybackState(
playing: playing,
position: _currentPosition,
duration: player.state.duration,
);
});
player.stream.position.listen((position) {
_currentPosition = position;
final now = DateTime.now();
if (now.difference(_lastPublishTime) >= _publishInterval) {
_lastPublishTime = now;
_publishPlaybackState(
playing: player.state.playing,
position: position,
duration: player.state.duration,
onlyPosition: true,
);
}
});
player.stream.duration.listen((duration) {
if (_currentId != null && _currentTitle != null) {
_updateMediaItem(
id: _currentId!,
title: _currentTitle!,
artist: _currentArtist!,
duration: duration,
);
}
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: duration,
);
});
}
// ═══════════════════════════════════════════════════════════════
// 外部同步接口(由 AudioService 调用)
// ═══════════════════════════════════════════════════════════════
void syncState(Song? song) {
if (song == null) {
_currentId = null;
_currentTitle = null;
_currentArtist = null;
return;
}
_updateMediaItem(
id: song.id,
title: song.title,
artist: song.artist,
artwork: song.artwork,
);
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: player.state.duration,
);
}
// ═══════════════════════════════════════════════════════════════
// 状态发布
// ═══════════════════════════════════════════════════════════════
void _publishPlaybackState({
required bool playing,
required Duration position,
required Duration duration,
bool onlyPosition = false,
}) {
if (_isPublishing) return;
_isPublishing = true;
try {
final controls = _buildControls(playing);
// audio_service 侧映射:idle -> STATE_NONE,ready -> PLAYING/PAUSED。
// 之前只按 duration>0 判断,直播流/时长未解析出来时会被系统判定为"未在播放"
// (播放设备选择页、部分车机与耳机都会读到这个状态)。
final prepared = playing || duration.inMilliseconds > 0;
final state = audio_service.PlaybackState(
controls: controls,
processingState: prepared
? audio_service.AudioProcessingState.ready
: audio_service.AudioProcessingState.idle,
playing: playing,
updatePosition: position,
updateTime: DateTime.now(),
systemActions: const {audio_service.MediaAction.seek},
);
playbackState.add(state);
} finally {
_isPublishing = false;
}
}
List<audio_service.MediaControl> _buildControls(bool playing) {
return [
const audio_service.MediaControl(
androidIcon: 'drawable/ic_previous',
label: '上一曲',
action: audio_service.MediaAction.skipToPrevious,
),
audio_service.MediaControl(
androidIcon: playing ? 'drawable/ic_pause' : 'drawable/ic_play',
label: playing ? '暂停' : '播放',
action: audio_service.MediaAction.playPause,
),
const audio_service.MediaControl(
androidIcon: 'drawable/ic_next',
label: '下一曲',
action: audio_service.MediaAction.skipToNext,
),
];
}
// ═══════════════════════════════════════════════════════════════
// 媒体信息更新
// ═══════════════════════════════════════════════════════════════
void _updateMediaItem({
required String id,
required String title,
required String artist,
Duration? duration,
Uint8List? artwork,
}) {
_currentId = id;
_currentTitle = title;
_currentArtist = artist;
final position = _playback.player.state.position;
_handleArtwork(id, artwork).then((artUri) {
final current = mediaItem.value;
if (current != null && current.artUri != artUri) {
mediaItem.add(audio_service.MediaItem(
id: current.id,
title: current.title,
artist: current.artist,
duration: current.duration,
artUri: artUri,
extras: current.extras,
));
}
});
mediaItem.add(audio_service.MediaItem(
id: id,
title: title,
artist: artist,
duration: duration ?? _playback.player.state.duration,
extras: {'position': position.inMilliseconds},
));
}
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
if (artwork == null || artwork.isEmpty) return null;
try {
final dir = await getApplicationDocumentsDirectory();
final artworkDir = Directory('${dir.path}/artworks');
if (!await artworkDir.exists()) {
await artworkDir.create(recursive: true);
}
final bytes = utf8.encode(id);
final digest = md5.convert(bytes);
final fileName = '$digest.jpg';
final path = '${artworkDir.path}/$fileName';
final file = File(path);
if (await file.exists()) {
final existingBytes = await file.readAsBytes();
if (existingBytes.length == artwork.length &&
existingBytes.hashCode == artwork.hashCode) {
_currentArtworkPath = path;
return await FileProviderUtils.getContentUri(file);
}
}
await file.writeAsBytes(artwork);
_currentArtworkPath = path;
return await FileProviderUtils.getContentUri(file);
} catch (e) {
debugPrint('⚠️ [handler] artwork handling failed: $e');
return null;
}
}
void updateNotification({
required String id,
required String title,
required String artist,
Uint8List? artwork,
}) {
_updateMediaItem(id: id, title: title, artist: artist, artwork: artwork);
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: _currentPosition,
duration: player.state.duration,
);
}
// ═══════════════════════════════════════════════════════════════
// audio_service 控制命令(全部委托给 PlaybackService)
// ═══════════════════════════════════════════════════════════════
@override
Future<void> play() async {
debugPrint('▶️ [handler] play() called');
// 走 AudioService 的统一入口:冷启动首次播放需要先 open + seek
await AudioService().playOrResume();
}
@override
Future<void> pause() async {
debugPrint('⏸️ [handler] pause() called');
await _playback.pause();
}
@override
Future<void> stop() async {
debugPrint('⏹️ [handler] stop() called');
await _playback.stop();
}
@override
Future<void> seek(Duration position) async {
debugPrint('⏩ [handler] seek() called: $position');
await _playback.seek(position);
_currentPosition = position;
final player = _playback.player;
_publishPlaybackState(
playing: player.state.playing,
position: position,
duration: player.state.duration,
onlyPosition: true,
);
}
@override
Future<void> skipToNext() async {
debugPrint('⏭️ [handler] skipToNext() called');
AudioService().next();
}
@override
Future<void> skipToPrevious() async {
debugPrint('⏮️ [handler] skipToPrevious() called');
AudioService().previous();
}
@override
Future<void> click(
[audio_service.MediaButton button =
audio_service.MediaButton.media]) async {
debugPrint('🎯 [handler] click() called with button: ${button.name}');
switch (button) {
case audio_service.MediaButton.next:
await skipToNext();
break;
case audio_service.MediaButton.previous:
await skipToPrevious();
break;
case audio_service.MediaButton.media:
if (_playback.player.state.playing) {
await pause();
} else {
await play();
}
break;
default:
if (_playback.player.state.playing) {
await pause();
} else {
await play();
}
}
}
}