Files
QingTing-Player/lib/services/audio_service.dart
T
lxh2875931338 ca4546871c fix(playback): 位置回退保护 + 恢复用最后已知位置 + 缓冲感知重试
对应 10/24 日志里的"播到一半中断后从头重播":
  23:05:26 ffurl_read -> 从 77s 重连成功
  23:05:33 再断 -> position 归 0 -> 看门狗"从 0s 继续",听感就是重新开始

- 新增 _lastKnownPosition:位置流累积,切歌归零;
  所有恢复路径(看门狗 / 网络切换 / 打开失败)都用它续播
- 位置回退保护(抽成 utils/position_guard.dart):
  已播 >10s 且新位置 <5s 时忽略回退,除非用户 seek / 切歌 / 正在恢复
- _scheduleOpenRetry 改为缓冲感知:
  缓冲余量 >=12s 时延迟 4s 重试(不打断),否则 1.2s;
  重试前若位置已推进超过 1s,判定 mpv 自愈,取消重连
- 收藏列表点击改为整队入队(setQueue,从点击项开始)

测试:新增 position_guard 6 个用例,累计 25 个全绿。
真机验证:收藏整队日志 "17 首,从第 16 首开始",播放正常。
2026-10-31 21:22:56 +08:00

1314 lines
44 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_service.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart' as media_kit;
import 'package:path_provider/path_provider.dart';
import 'playback_service.dart';
import 'network_watcher.dart';
import 'prefetch_service.dart';
import '../metadata/metadata_service.dart';
import '../database/song_database.dart';
import '../utils/artwork_helper.dart';
import '../utils/song_key.dart';
import '../utils/position_guard.dart';
import '../repositories/playlist_repository.dart';
import '../models/playlist.dart';
import 'webdav_service.dart';
import 'audio_player_handler.dart';
enum PlayMode {
sequential,
repeatOne,
shuffle,
}
class Song {
final String id;
final String title;
final String artist;
final String? url;
final Uint8List? artwork;
Song({
required this.id,
required this.title,
required this.artist,
this.url,
this.artwork,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'artist': artist,
'url': url,
};
}
class AudioService extends ChangeNotifier {
static final AudioService _instance = AudioService._internal();
factory AudioService() => _instance;
AudioService._internal();
// ---- 基础状态 ----
Song? _currentSong;
bool _isPlaying = false;
PlayMode _playMode = PlayMode.sequential;
// ---- 播放队列 ----
List<Song> _queue = [];
int _currentIndex = -1;
List<int> _shuffledIndices = [];
int _shuffledIndex = -1;
// ---- 当前播放的歌单 ID ----
String? _currentPlaylistId;
// ⭐ 插入位置:在现有成员变量之后,方法之前
AudioPlayerHandler? _handler; // ⭐ 添加这一行
// ---- 高频进度 ----
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
final ValueNotifier<Duration> bufferedNotifier = ValueNotifier(Duration.zero);
bool _listening = false;
final List<StreamSubscription> _subscriptions = [];
// ---- 播放生命周期标志 ----
bool _handlingCompletion = false;
bool _isChangingTrack = false;
bool _hasStartedCurrentPlayback = false;
bool _isUserSeeking = false;
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
int _switchEpoch = 0;
/// 卡死判定阈值 / 节流
static const Duration _stallThreshold = Duration(seconds: 20);
static const Duration _startStallThreshold = Duration(seconds: 10);
DateTime? _lastStallRecoveryAt;
/// 最近一次进入 buffering 的时间(用于卡死判定,不能用计时器重置)
DateTime? _lastBufferingAt;
/// 网络切换去抖
Timer? _networkDebounce;
/// 进度看门狗:某些卡死不会触发 buffering 事件(例如 CDN 半开连接),
/// 用「播放中进度长时间不动」兜底。
Timer? _progressTimer;
Duration _lastObservedPosition = Duration.zero;
DateTime _lastProgressAt = DateTime.now();
/// 最后已知的有效播放位置。
///
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
Duration _lastKnownPosition = Duration.zero;
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
bool _isRecovering = false;
/// 当前歌曲开始加载的时间,用于"起播看门狗":
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
DateTime? _trackStartAt;
DateTime? _playReadyAt;
int _prefetchedGeneration = -1;
/// 元数据回退到文件名时置位,播到 80% 再试一次
bool _metadataNeedsRetry = false;
int _metadataRetryGeneration = -1;
bool _firstAudioReported = false;
/// 冷启动恢复后尚未真正打开媒体:首次点击播放走 _playCurrent(含 seek)
bool _restoredPendingOpen = false;
/// 播放中定时持久化位置(冷启动能回到准确位置)
DateTime _lastPositionSaveAt = DateTime.now();
/// 打开失败后的快速重试(每首歌最多一次)
int _openRetryGeneration = -1;
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
String? _lastPlaybackError;
String? get lastPlaybackError => _lastPlaybackError;
int _playbackGeneration = 0;
// ---- 待恢复的播放进度 ----
Duration? _pendingSeekPosition;
void Function(Song)? _onSongChanged;
// ---- Repository ----
final SongDatabase _db = SongDatabase();
final PlaylistRepository _playlistRepo = PlaylistRepository();
// ---- Getter ----
Song? get currentSong => _currentSong;
bool get isPlaying => _isPlaying;
PlayMode get playMode => _playMode;
List<Song> get queue => List.unmodifiable(_queue);
int get currentIndex => _currentIndex;
bool get hasQueue => _queue.isNotEmpty;
String? get currentPlaylistId => _currentPlaylistId;
Duration get position => positionNotifier.value;
Duration get duration => durationNotifier.value;
Duration get bufferedPosition => bufferedNotifier.value;
IconData get playModeIcon {
switch (_playMode) {
case PlayMode.sequential:
return Icons.repeat;
case PlayMode.repeatOne:
return Icons.repeat_one;
case PlayMode.shuffle:
return Icons.shuffle;
}
}
void setOnSongChanged(void Function(Song) callback) {
_onSongChanged = callback;
}
void setHandler(AudioPlayerHandler handler) {
// ⭐ 添加这个方法
_handler = handler;
}
void togglePlayMode() {
switch (_playMode) {
case PlayMode.sequential:
_playMode = PlayMode.repeatOne;
break;
case PlayMode.repeatOne:
_playMode = PlayMode.shuffle;
break;
case PlayMode.shuffle:
_playMode = PlayMode.sequential;
break;
}
notifyListeners();
if (_currentPlaylistId != null) {
_playlistRepo.updatePlaylistPlayMode(_currentPlaylistId!, _playMode);
}
savePlaybackState(); // 模式改变时保存
}
// ════════════════════════════════════════════════════════════
// 队列管理
// ════════════════════════════════════════════════════════════
Future<void> setQueue(List<Song> queue, {int startIndex = 0}) async {
if (queue.isEmpty) {
_clearQueue();
return;
}
_queue = List.from(queue);
_currentIndex = startIndex.clamp(0, _queue.length - 1);
if (_playMode == PlayMode.shuffle) {
_shuffledIndices = List.generate(_queue.length, (i) => i);
_shuffledIndices.shuffle();
_shuffledIndex = _shuffledIndices.indexOf(_currentIndex);
if (_shuffledIndex == -1) {
_shuffledIndex = 0;
_currentIndex = _shuffledIndices[0];
}
} else {
_shuffledIndices = List.generate(_queue.length, (i) => i);
_shuffledIndex = _currentIndex;
}
await _playCurrent();
}
void _clearQueue() {
_queue.clear();
_currentIndex = -1;
_shuffledIndices.clear();
_shuffledIndex = -1;
_currentPlaylistId = null;
stopPlay();
// 清空队列时也清除持久化状态
_db.savePlaybackState(
queueJson: [],
currentIndex: 0,
playMode: 'sequential',
positionMs: 0,
);
}
Future<void> playSong(Song song) async {
_currentPlaylistId = null;
if (_queue.isEmpty || _queue[_currentIndex].id != song.id) {
await setQueue([song], startIndex: 0);
} else {
await _playCurrent();
}
}
// ════════════════════════════════════════════════════════════
// 歌单
// ════════════════════════════════════════════════════════════
Future<void> playPlaylist(String playlistId, {int startIndex = 0}) async {
_currentPlaylistId = playlistId;
final playlist = await _playlistRepo.getPlaylist(playlistId);
if (playlist != null) {
_playMode = playlist.playMode;
}
final songIds = await _playlistRepo.getPlaylistSongIds(playlistId);
if (songIds.isEmpty) return;
final songs = <Song>[];
for (final id in songIds) {
final dbSong = await _db.getSongByPath(id);
if (dbSong != null) {
songs.add(Song(
id: dbSong['remote_path'] as String,
title: dbSong['title'] as String? ?? '',
artist: dbSong['artist'] as String? ?? '未知艺术家',
url: dbSong['remote_path'] as String,
));
} else {
songs.add(Song(
id: id,
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
artist: '未知艺术家',
url: id,
));
}
}
if (songs.isNotEmpty) {
await setQueue(songs, startIndex: startIndex);
}
}
Future<Playlist> saveQueueAsPlaylist(String name) async {
final playlist = await _playlistRepo.createPlaylist(name);
for (int i = 0; i < _queue.length; i++) {
final song = _queue[i];
await _playlistRepo.addSong(playlist.id, song.id);
}
return playlist;
}
// ════════════════════════════════════════════════════════════
// ⭐ 播放核心
// ════════════════════════════════════════════════════════════
Future<void> _playCurrent() async {
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
stopPlay();
return;
}
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch;
_restoredPendingOpen = false;
_isChangingTrack = true;
_handlingCompletion = false;
_hasStartedCurrentPlayback = false;
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
_lastKnownPosition = Duration.zero;
try {
_playbackGeneration++;
final generation = _playbackGeneration;
final song = _queue[_currentIndex];
_currentSong = song;
_handler?.syncState(song);
_startListening();
notifyListeners();
if (song.url == null || song.url!.isEmpty) {
return;
}
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
_metadataNeedsRetry = false;
await _playWithHeaders(song.url!);
_playReadyAt = DateTime.now();
} 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) {
final pos = _pendingSeekPosition!;
_pendingSeekPosition = null;
await PlaybackService().seek(pos);
positionNotifier.value = pos;
debugPrint('🎯 [AudioService] restored position: $pos');
}
_syncPlayerStateDelayed();
if (_hasStartedCurrentPlayback) {
_lastPlaybackError = null;
_onSongChanged?.call(song);
// 不在起播瞬间抓元数据:此时 mpv 正在填缓冲区,
// 2MB 的标签请求会和播放流抢带宽,实测会把"下一首"拖到 60s+。
_scheduleMetadataWhenSettled(generation, epoch);
}
// 切歌完成后保存状态
await savePlaybackState();
} finally {
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
if (epoch == _switchEpoch) {
_isChangingTrack = false;
}
}
}
/// 等播放真正稳定后再抓元数据。
///
/// 判定条件比"能出声"更严格:进度至少 5s、且连续 2s 不处于 buffering。
/// 原因:公网链路上新歌起播后 mpv 还在持续填缓冲,
/// 这时候插一个 2MB 的标签下载会直接把缓冲打回 0(实测下一首要等 60s+)。
/// 最多等 30s;等不到就放弃这次抓取(下次播放会再试)。
Future<bool> _waitForPlaybackSettled(int epoch,
{Duration timeout = const Duration(seconds: 30)}) async {
final player = PlaybackService().player;
final deadline = DateTime.now().add(timeout);
var quietChecks = 0;
while (DateTime.now().isBefore(deadline)) {
if (epoch != _switchEpoch) return false;
final ready = player.state.playing &&
!player.state.buffering &&
player.state.position >= const Duration(seconds: 5);
if (ready) {
quietChecks++;
// 每 500ms 采一次,连续 4 次即稳定 2 秒
if (quietChecks >= 4) return true;
} else {
quietChecks = 0;
}
await Future.delayed(const Duration(milliseconds: 500));
}
return false;
}
void _scheduleMetadataWhenSettled(int generation, int epoch) {
// 故意不 await:不能阻塞切歌流程和状态保存
() async {
try {
final settled = await _waitForPlaybackSettled(epoch);
if (!settled || epoch != _switchEpoch) {
debugPrint('⏸️ [AudioService] 播放未稳定,跳过本次元数据抓取(避免抢带宽)');
_metadataNeedsRetry = true; // 留到 80% 再试
return;
}
await _loadMetadataForCurrentSong(generation);
} catch (e) {
debugPrint('⚠️ [AudioService] 延迟元数据抓取失败: $e');
}
}();
}
/// 播到 80% 时,对"元数据回退到文件名"的歌再抓一次。
///
/// 原因:反代在等上游首包超时时会直接回 502(实测 10.3s),
/// 起播阶段抓取很容易撞上;到 80% 时链路通常已经热了,重试成功率更高。
/// 每首歌最多重试一次。
void _maybeRetryMetadata(Duration position) {
if (!_metadataNeedsRetry) return;
final duration = durationNotifier.value;
if (duration.inMilliseconds <= 0) return;
if (position.inMilliseconds < duration.inMilliseconds * 0.8) return;
if (_metadataRetryGeneration == _playbackGeneration) return;
_metadataRetryGeneration = _playbackGeneration;
_metadataNeedsRetry = false;
debugPrint('🔁 [AudioService] 播放到 80%,重试一次元数据抓取');
_loadMetadataForCurrentSong(_playbackGeneration);
}
/// 播到 60% 时预热下一首(HEAD + Range 0-0,不下载正文)。
///
/// 日志实测起播延迟 2~3s 都在"等首批数据",提前完成 DNS/TLS/反代握手
/// 可以把这段时间压掉一部分;失败完全静默,不影响当前播放。
void _maybePrefetchNext(Duration position) {
final duration = durationNotifier.value;
if (duration.inSeconds < 60) return;
if (position.inMilliseconds < duration.inMilliseconds * 0.6) return;
if (_prefetchedGeneration == _playbackGeneration) return;
final next = _peekNextSong();
final url = next?.url;
if (url == null || url.isEmpty) return;
_prefetchedGeneration = _playbackGeneration;
debugPrint('🔥 [AudioService] 预热下一首:${next!.title}');
PrefetchService().warm(url);
}
/// 只推算下一首,不改变队列/索引(next() 会改)。
Song? _peekNextSong() {
if (_queue.isEmpty) return null;
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return null;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
return _queue[_shuffledIndices[nextIdx]];
}
final nextIdx = (_currentIndex + 1) % _queue.length;
return _queue[nextIdx];
}
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
///
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
/// 每次 true 若都重置计时器,阈值永远不会到(实测公网链路抖动 60s+ 不恢复)。
/// 为什么不能只看进度:用户暂停时进度也不动,会误触发。
/// 两个条件同时满足即可区分。
void _checkPlaybackProgress() {
if (_isChangingTrack) {
// 切歌中不参与判断,重置基准
_lastObservedPosition = positionNotifier.value;
_lastProgressAt = DateTime.now();
return;
}
final now = DateTime.now();
final pos = positionNotifier.value;
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 不会自己恢复,通用 20s 规则又太慢,单独用 10s 阈值。
final stuckAtStart = pos == Duration.zero &&
_trackStartAt != null &&
now.difference(_trackStartAt!) >= _startStallThreshold &&
player.state.playing;
if (stuckAtStart) {
debugPrint('🩺 [AudioService] 起播 '
'${_startStallThreshold.inSeconds}s 仍停在 0s,触发重连');
_trackStartAt = now; // 避免下一次 tick 立刻重复触发
_recoverFromStall(force: true);
return;
}
final stalledFor = now.difference(_lastProgressAt);
final recentlyBuffering = _lastBufferingAt != null &&
now.difference(_lastBufferingAt!) < const Duration(seconds: 20);
// playing==true 也是卡死的充分条件(暂停时 playing 为 false)
if (stalledFor >= _stallThreshold &&
(player.state.playing || recentlyBuffering)) {
debugPrint('🩺 [AudioService] 进度停在 $pos 已 ${stalledFor.inSeconds}s,'
'触发自愈');
_lastProgressAt = now;
_recoverFromStall(force: true);
}
}
/// 打开失败(ffurl_read / Failed to open)后 1.5s 立即重试一次。
///
/// 实测:切歌后 mpv 在 5~6s 时放弃打开,旧逻辑要等起播看门狗
/// (5s 一跳 × 10s 阈值,最坏 20s)才重连,表现为十几秒的前摇。
/// 每首歌最多快速重试一次,避免失败风暴。
void _scheduleOpenRetry() {
if (_currentSong == null) return;
if (_isChangingTrack) return;
if (_openRetryGeneration == _playbackGeneration) return;
_openRetryGeneration = _playbackGeneration;
// 缓冲余量决定重试节奏:
// 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉;
// 余量见底 -> 尽快重连,尽量减少静音时间。
final bufferAhead = bufferedNotifier.value - positionNotifier.value;
final hasBuffer = bufferAhead >= const Duration(seconds: 12);
final delay = hasBuffer
? const Duration(seconds: 4)
: const Duration(milliseconds: 1200);
final baseline = _lastKnownPosition;
debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,'
'${delay.inMilliseconds}ms 后重试');
Timer(delay, () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
// mpv 可能自己接上了:位置在推进就不打断它
if (positionNotifier.value >
baseline + const Duration(seconds: 1)) {
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
return;
}
_recoverFromStall(force: true);
});
}
/// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。
///
/// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。
Future<void> _recoverFromStall({bool force = false}) 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 (!force &&
_lastStallRecoveryAt != null &&
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
return;
}
_lastStallRecoveryAt = now;
final resumeAt = _lastKnownPosition > Duration.zero
? _lastKnownPosition
: positionNotifier.value;
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
_isRecovering = true;
try {
_trackStartAt = DateTime.now();
_playReadyAt = null;
_firstAudioReported = false;
await _playWithHeaders(url);
_playReadyAt = DateTime.now();
if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt;
_lastKnownPosition = resumeAt;
}
debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners();
} finally {
_isRecovering = false;
}
}
/// 网络切换(Wi-Fi 换 AP、Wi-Fi <-> 移动数据)后的主动重连。
///
/// 长连接会随出口 IP 变化失效,mpv 不保证会自己重连。
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
void _onNetworkChanged() {
_networkDebounce?.cancel();
_networkDebounce = Timer(const Duration(seconds: 2), () {
if (_currentSong == null) return;
if (_isChangingTrack) return;
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
_recoverFromStall(force: true);
});
}
Future<void> _waitForPlaybackStarted() async {
final player = PlaybackService().player;
if (player.state.playing) {
_hasStartedCurrentPlayback = true;
debugPrint('🎵 [AudioService] playback already started');
return;
}
try {
await player.stream.playing
.where((playing) => playing == true)
.first
.timeout(const Duration(seconds: 3));
_hasStartedCurrentPlayback = true;
debugPrint('🎵 [AudioService] playback started');
} catch (e) {
debugPrint('⚠️ [AudioService] wait for playback timeout: $e');
_hasStartedCurrentPlayback = true;
}
}
Future<void> _playWithHeaders(String url) async {
final headers = await _getAuthHeadersForUrl(url);
await PlaybackService().play(url, headers: headers);
}
Future<Map<String, String>> _getAuthHeadersForUrl(String url) async {
if (url.startsWith('http://') || url.startsWith('https://')) {
try {
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isNotEmpty) {
debugPrint('🔑 [AudioService] 已添加认证头到播放请求');
return headers;
}
} catch (e) {
debugPrint('⚠️ [AudioService] 获取认证头失败: $e');
}
}
return {};
}
String _generateSongKey(String url) {
return songKeyFor(url);
}
// ════════════════════════════════════════════════════════════
// ⭐ Metadata 加载(四层校验)
// ════════════════════════════════════════════════════════════
Future<void> _loadMetadataForCurrentSong(int generation) async {
if (_currentIndex < 0 || _currentIndex >= _queue.length) return;
final currentIndex = _currentIndex;
final song = _queue[currentIndex];
final songId = song.id;
if (song.url == null || song.url!.isEmpty) {
_onSongChanged?.call(song);
return;
}
try {
final metadata = await MetadataService().getMetadata(
url: song.url!,
fileName: song.title,
fileId: song.id,
);
// 第一层:generation 校验
if (_playbackGeneration != generation) {
debugPrint('⚠️ [AudioService] metadata stale (generation)');
return;
}
// 第二层:索引校验
if (_currentIndex != currentIndex) {
debugPrint('⚠️ [AudioService] metadata stale (index changed)');
return;
}
// 第三层:歌曲 ID 校验
if (_currentIndex >= _queue.length ||
_queue[_currentIndex].id != songId) {
debugPrint('⚠️ [AudioService] metadata stale (song changed)');
return;
}
// 第四层:_currentSong 校验
if (_currentSong == null || _currentSong!.id != songId) {
debugPrint('⚠️ [AudioService] metadata stale: current song mismatch');
return;
}
if (metadata.isNotEmpty) {
// 来源是文件名 = 元数据没读到(常见于反代 502 / 超时)
// 记下来,播到 80% 时再试一次,避免这首歌永远只有文件名
if (metadata.source == 'filename') {
_metadataNeedsRetry = true;
debugPrint('⚠️ [AudioService] 元数据回退到文件名,将在 80% 处重试一次');
} else {
_metadataNeedsRetry = false;
}
final updatedSong = Song(
id: song.id,
title: metadata.title.isNotEmpty ? metadata.title : song.title,
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
url: song.url,
artwork: metadata.artwork,
);
_queue[_currentIndex] = updatedSong;
_currentSong = updatedSong;
notifyListeners();
_onSongChanged?.call(updatedSong);
debugPrint(
'✅ [AudioService] metadata updated: ${updatedSong.title} - ${updatedSong.artist} (gen $generation)');
}
} catch (e) {
debugPrint('⚠️ [AudioService] metadata load failed: $e');
_onSongChanged?.call(song);
}
}
Future<void> clearCurrentSongCache() async {
if (_currentSong == null) return;
final song = _currentSong!;
final url = song.url ?? '';
if (url.isEmpty) return;
debugPrint('🗑️ [AudioService] clearing cache for: ${song.title}');
final currentIndex = _currentIndex;
String songKey;
try {
final provider = MetadataService().getProviderForUrl(url);
final file = await provider.getFile(url);
if (file != null && await file.exists()) {
final stat = await file.stat();
songKey = _generateSongKey(url);
} else {
songKey = songKeyForUrl(url);
}
} catch (e) {
songKey = songKeyForUrl(url);
}
final db = SongDatabase();
await db.deleteSong(songKey);
await db.deleteCache(songKey);
debugPrint('🗑️ [AudioService] SQLite records deleted: $songKey');
await ArtworkHelper.deleteArtwork(songKey);
debugPrint('🗑️ [AudioService] artwork deleted');
try {
final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
final cacheFile = File(cachePath);
if (await cacheFile.exists()) {
await cacheFile.delete();
debugPrint('🗑️ [AudioService] temp cache file deleted');
}
} catch (e) {
// 忽略
}
await MetadataService().clearCache(song.id);
debugPrint('🗑️ [AudioService] memory cache cleared');
if (currentIndex >= 0 && currentIndex < _queue.length) {
stopPlay();
await _playCurrent();
debugPrint('🔄 [AudioService] song reloaded');
}
}
// ════════════════════════════════════════════════════════════
// ⭐ 上一首 / 下一首(带防并发)
// ════════════════════════════════════════════════════════════
Future<void> next() async {
debugPrint(
'🎵 [next] CALLED: index=$_currentIndex, total=${_queue.length}, isChangingTrack=$_isChangingTrack');
if (_queue.isEmpty) {
debugPrint('⚠️ [next] queue is empty');
return;
}
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
_shuffledIndex = nextIdx;
_currentIndex = _shuffledIndices[nextIdx];
await _playCurrent();
return;
}
final nextIdx = (_currentIndex + 1) % _queue.length;
_currentIndex = nextIdx;
await _playCurrent();
}
Future<void> previous() async {
if (_queue.isEmpty) return;
// 同 next():最新请求胜出,不拒绝
if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
if (prevIdx < 0) {
_shuffledIndex = _shuffledIndices.length - 1;
} else {
_shuffledIndex = prevIdx;
}
_currentIndex = _shuffledIndices[_shuffledIndex];
await _playCurrent();
return;
}
final prevIdx = (_currentIndex - 1) % _queue.length;
if (prevIdx < 0) {
_currentIndex = _queue.length - 1;
} else {
_currentIndex = prevIdx;
}
await _playCurrent();
}
// ---- 播放/暂停 ----
void togglePlay() {
if (_currentSong == null) return;
if (_isPlaying) {
PlaybackService().pause();
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();
}
void stopPlay() {
// 停止底层播放器
PlaybackService().stop(); // 新增
_currentSong = null;
_isPlaying = false;
positionNotifier.value = Duration.zero;
durationNotifier.value = Duration.zero;
bufferedNotifier.value = Duration.zero;
_stopListening();
notifyListeners();
savePlaybackState();
}
void seekTo(Duration position) {
_isUserSeeking = true;
PlaybackService().seek(position);
positionNotifier.value = position;
Future.delayed(const Duration(milliseconds: 800), () {
_isUserSeeking = false;
});
// 拖动后保存进度
savePlaybackState();
}
void clearQueue() {
_queue.clear();
_currentIndex = -1;
_shuffledIndices.clear();
_shuffledIndex = -1;
_currentPlaylistId = null;
stopPlay();
notifyListeners();
}
void syncPlayerStateNow() {
final player = PlaybackService().player;
final pos = player.state.position;
final dur = player.state.duration;
final buf = player.state.buffer;
debugPrint(
'🎯 [AudioService] syncPlayerStateNow: pos=$pos, dur=$dur, buf=$buf');
if (pos.inMilliseconds >= 0) {
positionNotifier.value = pos;
}
if (dur.inMilliseconds > 0) {
durationNotifier.value = dur;
}
if (buf.inMilliseconds >= 0) {
bufferedNotifier.value = buf;
}
}
void _syncPlayerStateDelayed() {
syncPlayerStateNow();
Future.delayed(const Duration(milliseconds: 200), () {
syncPlayerStateNow();
});
Future.delayed(const Duration(milliseconds: 500), () {
syncPlayerStateNow();
});
}
// ---- 监听 media_kit 状态 ----
void _startListening() {
if (_listening) return;
_listening = true;
final player = PlaybackService().player;
_subscriptions.add(
player.stream.playing.listen((playing) {
if (_isPlaying != playing) {
_isPlaying = playing;
notifyListeners();
// 播放状态变化时保存进度(暂停时已保存,但播放开始也可保存一次)
if (playing) savePlaybackState();
}
}),
);
_subscriptions.add(
player.stream.position.listen((position) {
if (_isUserSeeking) {
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
return;
}
// 位置回退保护:打开失败/重连时 mpv 会短暂报 0。
// 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s,
// 否则界面进度会归零,恢复时也会"从头播"。
if (shouldIgnorePositionRegression(
lastKnown: _lastKnownPosition,
incoming: position,
isChangingTrack: _isChangingTrack,
isRecovering: _isRecovering,
)) {
debugPrint('🛡️ [AudioService] 忽略位置回退: '
'${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s');
return;
}
if (position > _lastKnownPosition ||
_isChangingTrack ||
_isRecovering) {
_lastKnownPosition = position;
}
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
// 与 play() 发起时间相减即可算出起播延迟。
if (!_firstAudioReported && position > Duration.zero) {
_firstAudioReported = true;
final readyAt = _playReadyAt;
if (readyAt != null) {
final ms = DateTime.now().difference(readyAt).inMilliseconds;
debugPrint('🔊 [AudioService] 起播耗时 ${ms}ms(open 完成 -> 出声)');
_playReadyAt = null;
}
}
_maybeRetryMetadata(position);
_maybePrefetchNext(position);
positionNotifier.value = position;
}),
);
_subscriptions.add(
player.stream.duration.listen((duration) {
debugPrint('🎯 [AudioService] durationStream: $duration');
if (duration.inMilliseconds > 0) {
durationNotifier.value = duration;
}
}),
);
_subscriptions.add(
player.stream.buffer.listen((buffer) {
bufferedNotifier.value = buffer;
}),
);
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
_subscriptions.add(
player.stream.error.listen((error) {
debugPrint('❌ [AudioService] player error: $error');
_lastPlaybackError = error;
notifyListeners();
// 打开失败(ffurl_read / Failed to open)时立刻安排一次重试,
// 不要等 10~20s 的起播看门狗。
if (error.contains('Failed to open') ||
error.contains('ffurl_read')) {
_scheduleOpenRetry();
}
}),
);
// 缓冲状态:只记录"最近一次进入缓冲的时间"。
// 不能用 Timeout 重置计时:弱网下 buffering 会 true/false 反复抖动,
// 每次 true 都重置的话,30s 阈值永远不会到。
_subscriptions.add(
player.stream.buffering.listen((buffering) {
debugPrint('🌊 [AudioService] buffering=$buffering');
if (buffering) {
_lastBufferingAt = DateTime.now();
}
}),
);
// 进度看门狗:播放中进度 30s 不动、且近期发生过缓冲 → 判定卡死并自愈。
// 用"进度 + 最近缓冲"两个条件,既不会在暂停时误触发,
// 也能覆盖 buffering 反复抖动、始终起不来的情况。
_progressTimer?.cancel();
_lastProgressAt = DateTime.now();
_progressTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => _checkPlaybackProgress(),
);
// 网络变化监听:换 Wi-Fi / 切移动数据后长连接会失效,主动重连
NetworkWatcher().onChanged = _onNetworkChanged;
NetworkWatcher().start();
// ⭐ 完整生命周期校验的 completed 监听
_subscriptions.add(
player.stream.completed.listen((_) {
debugPrint(
'🎵 [completed event] RAW: index=$_currentIndex, isChangingTrack=$_isChangingTrack');
if (_isChangingTrack) {
debugPrint('⚠️ [completed] REJECTED: track switch in progress');
return;
}
if (!_hasStartedCurrentPlayback) {
debugPrint('⚠️ [completed] REJECTED: playback never started');
return;
}
if (_handlingCompletion) {
debugPrint('⚠️ [completed] REJECTED: already handling');
return;
}
final pos = player.state.position;
final dur = player.state.duration;
debugPrint('🎵 [completed] pos=$pos, dur=$dur');
if (dur.inMilliseconds <= 0) {
debugPrint('⚠️ [completed] REJECTED: invalid duration $dur');
return;
}
final diff = dur.inMilliseconds - pos.inMilliseconds;
debugPrint('🎵 [completed] diff=${diff}ms');
if (pos.inMilliseconds < dur.inMilliseconds - 800) {
debugPrint('⚠️ [completed] REJECTED: not at end, diff=${diff}ms');
return;
}
debugPrint('✅ [completed] ACCEPTED: index=$_currentIndex');
_handlingCompletion = true;
_onPlaybackCompleted();
}),
);
}
void _stopListening() {
_listening = false;
for (final subscription in _subscriptions) {
subscription.cancel();
}
_subscriptions.clear();
}
void _onPlaybackCompleted() {
debugPrint(
'🎵 [onPlaybackCompleted] ENTERED: index=$_currentIndex, total=${_queue.length}, mode=$_playMode');
if (_queue.isEmpty) {
debugPrint('⚠️ [onPlaybackCompleted] queue is empty, returning');
_handlingCompletion = false;
return;
}
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
debugPrint(
'⚠️ [onPlaybackCompleted] invalid index: $_currentIndex, total=${_queue.length}');
_handlingCompletion = false;
return;
}
final isLastSong = _currentIndex + 1 >= _queue.length;
if (isLastSong && _playMode != PlayMode.repeatOne) {
debugPrint(
'⚠️ [onPlaybackCompleted] LAST SONG: index=$_currentIndex, mode=$_playMode, no next()');
_handlingCompletion = false;
return;
}
debugPrint(
'🎵 [onPlaybackCompleted] will call next(), index=$_currentIndex');
try {
if (_playMode == PlayMode.repeatOne) {
debugPrint(
'🎵 [onPlaybackCompleted] repeatOne mode, calling _playCurrent()');
_playCurrent();
return;
}
debugPrint('🎵 [onPlaybackCompleted] calling next()');
next();
} finally {
_handlingCompletion = false;
}
}
// ════════════════════════════════════════════════════════════
// ⭐ 播放状态持久化
// ════════════════════════════════════════════════════════════
/// 保存当前播放状态
Future<void> savePlaybackState() async {
if (_queue.isEmpty) {
// 空队列时保存空状态
await _db.savePlaybackState(
queueJson: [],
currentIndex: 0,
playMode: 'sequential',
positionMs: 0,
);
return;
}
final queueJson = _queue.map((song) => song.toJson()).toList();
final modeStr = _playMode == PlayMode.sequential
? 'sequential'
: _playMode == PlayMode.repeatOne
? 'repeat_one'
: 'shuffle';
await _db.savePlaybackState(
queueJson: queueJson,
currentIndex: _currentIndex,
playMode: modeStr,
currentPlaylistId: _currentPlaylistId,
positionMs: positionNotifier.value.inMilliseconds,
);
debugPrint('💾 [AudioService] playback state saved');
}
/// 恢复播放状态
/// 恢复播放状态
Future<bool> restorePlaybackState() async {
final state = await _db.getPlaybackState();
if (state == null) return false;
try {
final queueJson = jsonDecode(state['queue_json'] as String) as List;
if (queueJson.isEmpty) return false;
final queue = queueJson
.map((item) => Song(
id: item['id'] as String,
title: item['title'] as String? ?? '',
artist: item['artist'] as String? ?? '未知艺术家',
url: item['url'] as String?,
artwork: null,
))
.toList();
if (queue.isEmpty) return false;
_queue = queue;
_currentIndex = state['current_index'] as int;
if (_currentIndex >= _queue.length) _currentIndex = 0;
final modeStr = state['play_mode'] as String? ?? 'sequential';
_playMode = modeStr == 'sequential'
? PlayMode.sequential
: modeStr == 'repeat_one'
? PlayMode.repeatOne
: PlayMode.shuffle;
_currentPlaylistId = state['current_playlist_id'] as String?;
// 保存待恢复的进度
final savedPos = state['position_ms'] as int? ?? 0;
_pendingSeekPosition = Duration(milliseconds: savedPos);
final song = _queue[_currentIndex];
_currentSong = song;
_onSongChanged?.call(song);
_handler?.syncState(song); // ⭐ 添加这一行
debugPrint(
'♻️ [AudioService] playback state restored: ${song.title} - ${song.artist}');
// 冷启动只恢复 UI 状态,不联网预加载。
// 进程被系统回收后再回来时,预加载(play→seek→pause)会拖慢首屏,
// 还会和用户点播放的操作打架,表现为"假死十几秒"。
_restoredPendingOpen = true;
_isPlaying = false;
positionNotifier.value = _pendingSeekPosition ?? Duration.zero;
notifyListeners();
return true;
} catch (e) {
debugPrint('⚠️ [AudioService] restore playback state failed: $e');
return false;
}
}
// 说明:冷启动不再预加载音频(原 _loadRestoredPlayback)。
// 恢复只做 UI 状态,首次播放时由 togglePlay → _playCurrent 完成 open + seek。
@override
void dispose() {
// 先保存再清理
_progressTimer?.cancel();
_progressTimer = null;
_networkDebounce?.cancel();
_networkDebounce = null;
NetworkWatcher().stop();
savePlaybackState();
_stopListening();
positionNotifier.dispose();
durationNotifier.dispose();
bufferedNotifier.dispose();
PlaybackService().dispose();
super.dispose();
}
}