// 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; /// 本地封面文件路径(songs.artwork_path)。 /// 命中过元数据的歌直接用它显示封面,不必等元数据流程回来。 final String? artworkPath; Song({ required this.id, required this.title, required this.artist, this.url, this.artwork, this.artworkPath, }); Map 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 _queue = []; int _currentIndex = -1; List _shuffledIndices = []; int _shuffledIndex = -1; // ---- 当前播放的歌单 ID ---- String? _currentPlaylistId; // ⭐ 插入位置:在现有成员变量之后,方法之前 AudioPlayerHandler? _handler; // ⭐ 添加这一行 // ---- 高频进度 ---- final ValueNotifier positionNotifier = ValueNotifier(Duration.zero); final ValueNotifier durationNotifier = ValueNotifier(Duration.zero); final ValueNotifier bufferedNotifier = ValueNotifier(Duration.zero); bool _listening = false; final List _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; /// 当前这首歌已经连续自动恢复了几次(成功后清零)。 /// 打开一首本身拿不到数据的歌时,看门狗会无限重试, /// 必须熔断,否则日志和请求都会爆炸。 int _recoveryAttempts = 0; static const int _maxRecoveryAttempts = 3; /// 播放请求去重(同一次点击可能从多个入口触发) DateTime? _lastPlayRequestAt; int _lastPlayRequestIndex = -1; /// 恢复退避:拥塞网络里连续失败后拉长间隔,避免和整栋楼抢带宽。 DateTime? _nextRecoveryAt; Timer? _autoRetryTimer; static Duration _backoffFor(int attempts) { if (attempts <= 2) return Duration.zero; if (attempts == 3) return const Duration(seconds: 30); if (attempts == 4) return const Duration(seconds: 60); return const Duration(seconds: 120); } /// 当前歌曲开始加载的时间,用于"起播看门狗": /// 有些情况下 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 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 setQueue(List 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 playSong(Song song) async { _currentPlaylistId = null; if (_queue.isEmpty || _queue[_currentIndex].id != song.id) { await setQueue([song], startIndex: 0); } else { await _playCurrent(); } } // ════════════════════════════════════════════════════════════ // 歌单 // ════════════════════════════════════════════════════════════ Future 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 = []; for (final id in songIds) { final dbSong = await _db.getSongByPath(id); if (dbSong != null) { final path = dbSong['remote_path'] as String; songs.add(Song( id: path, artworkPath: dbSong['artwork_path'] as String?, title: dbSong['title'] as String? ?? '', artist: dbSong['artist'] as String? ?? '未知艺术家', url: WebDAVService.instance.getFileUrl(path), )); } else { songs.add(Song( id: id, title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''), artist: '未知艺术家', url: WebDAVService.instance.getFileUrl(id), )); } } if (songs.isNotEmpty) { await setQueue(songs, startIndex: startIndex); } } Future 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 _playCurrent() async { if (_currentIndex < 0 || _currentIndex >= _queue.length) { stopPlay(); return; } // 去重:同一次点击可能从多个入口触发(列表 onTap + 控制器 + 通知), // 反复 open 会互相顶掉,表现为"点了要等好几秒才出声"。 final now = DateTime.now(); if (_lastPlayRequestIndex == _currentIndex && _lastPlayRequestAt != null && now.difference(_lastPlayRequestAt!) < const Duration(milliseconds: 800)) { debugPrint('⏭️ [AudioService] 忽略重复播放请求(index=$_currentIndex)'); return; } _lastPlayRequestAt = now; _lastPlayRequestIndex = _currentIndex; // 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。 // 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。 final epoch = ++_switchEpoch; _restoredPendingOpen = false; _isChangingTrack = true; _handlingCompletion = false; _hasStartedCurrentPlayback = false; // 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置" _lastKnownPosition = Duration.zero; _recoveryAttempts = 0; _nextRecoveryAt = null; _autoRetryTimer?.cancel(); 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 _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); }); } /// 冷却结束后的自动重试(只试一次,成功与否交给下一轮判断) void _scheduleAutoRetry(Duration delay) { _autoRetryTimer?.cancel(); _autoRetryTimer = Timer(delay, () { _nextRecoveryAt = null; if (_currentSong == null) return; if (_isChangingTrack) return; if (positionNotifier.value > const Duration(seconds: 1)) return; debugPrint('⏰ [AudioService] 冷却结束,再试一次'); _recoverFromStall(force: true); }); } /// 卡死/网络切换自愈:重开当前歌曲并 seek 回原位置。 /// /// [force] 为 true 时跳过节流(用于网络切换这种明确的外部事件)。 Future _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; // 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。 // 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。 if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) { return; } if (_recoveryAttempts >= _maxRecoveryAttempts) { final delay = _backoffFor(_recoveryAttempts); _nextRecoveryAt = DateTime.now().add(delay); debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,' '${delay.inSeconds}s 后再试:${song.title}'); if (_recoveryAttempts >= _maxRecoveryAttempts + 3) { // 退避到极限:停住,交给用户手动重试(点播放会清零计数) debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}'); _lastPlaybackError = '网络拥塞,无法播放:${song.title}'; notifyListeners(); return; } _scheduleAutoRetry(delay); _recoveryAttempts++; return; } _recoveryAttempts++; final now = DateTime.now(); if (!force && _lastStallRecoveryAt != null && now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) { debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌'); return; } _lastStallRecoveryAt = now; // 恢复前记下代次:await 期间用户可能已经切歌, // 不能把旧歌重新打开(否则就是"点了新歌却切回旧歌")。 final recoveryGeneration = _playbackGeneration; 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); if (_playbackGeneration != recoveryGeneration) { debugPrint('⏭️ [AudioService] 恢复期间已切歌,放弃本次恢复'); return; } _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(); // 记录去抖开始时的歌:2 秒内用户可能已经切歌, // 那时再去重连"上一首"就是异常切换(日志里出现过 78s 重连旧歌)。 final expectedSongId = _currentSong?.id; final expectedGeneration = _playbackGeneration; _networkDebounce = Timer(const Duration(seconds: 2), () { if (_currentSong == null) return; if (_isChangingTrack) return; if (_currentSong?.id != expectedSongId || _playbackGeneration != expectedGeneration) { debugPrint('🌐 [AudioService] 网络变化期间已切歌,忽略本次重连'); return; } debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲'); _recoverFromStall(force: true); }); } Future _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 _playWithHeaders(String url) async { final headers = await _getAuthHeadersForUrl(url); await PlaybackService().play(url, headers: headers); } Future> _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 _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, // 保留封面文件路径:元数据流程回来前,播放页靠它立即显示封面 artworkPath: song.artworkPath, ); _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 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 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 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 playOrResume() async { if (_currentSong == null) return; if (_isPlaying) return; // 用户手动点播放:重置退避计数,允许立即重试 _recoveryAttempts = 0; _nextRecoveryAt = null; 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; } // 真的出声了(>1s),说明恢复成功,重置熔断计数 if (position > const Duration(seconds: 1)) { _recoveryAttempts = 0; _nextRecoveryAt = null; _autoRetryTimer?.cancel(); } // 首次拿到 >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 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 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; _autoRetryTimer?.cancel(); _autoRetryTimer = null; NetworkWatcher().stop(); savePlaybackState(); _stopListening(); positionNotifier.dispose(); durationNotifier.dispose(); bufferedNotifier.dispose(); PlaybackService().dispose(); super.dispose(); } }