fix(playback): 修复切歌永久假死,并让元数据抓取不再抢播放带宽

问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启):
1. _isChangingTrack 是没有超时的锁:_playCurrent() 置 true 后,
   只有走完 finally 才会清。mpv 的 open() 在弱网下可能挂很久,
   锁就一直不释放,之后 next()/previous() 全被静默拒绝。
2. 元数据用 dio.download 整文件下载,和 mpv 流式播放抢带宽,
   20Mbps FRP 下播放流只剩一两百 K/s,缓冲长期为 0。
3. 没有订阅 mpv 的 error/buffering,失败无感知、无法自愈。

改动:
- playback_service: open 20s / 控制命令 5s 超时;失败时 stop 回空闲并上抛异常
- audio_service:
  * 去掉「正在切歌就拒绝」的逻辑,改为 latest-wins(epoch 校验,
    旧请求自行放弃,只有最新请求解锁)
  * 播放失败不再吞掉:记录 lastPlaybackError 并 notifyListeners
  * 订阅 stream.error / stream.buffering
  * 缓冲超过 30s 自动重连当前歌曲并 seek 回原位置(2 分钟内最多一次)
- file_provider: 元数据改为 Range 只取头部 2MB,流式写入并在切歌时取消上一请求
- home_page: 去掉重复的 PlaybackService().play()(同一首歌会被 open 两次),
  登录校验提前

验证:dart analyze 无 error;gradlew assembleDebug BUILD SUCCESSFUL。
真机验证待设备重新连接后进行。
This commit is contained in:
2026-10-23 21:04:45 +08:00
parent eab4a12ccc
commit 9e58d52678
4 changed files with 232 additions and 51 deletions
+73 -12
View File
@@ -1,6 +1,6 @@
// lib/metadata/file_provider.dart // lib/metadata/file_provider.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; // ⭐ 添加 import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
@@ -11,46 +11,107 @@ abstract class FileProvider {
} }
class WebDAVFileProvider implements FileProvider { class WebDAVFileProvider implements FileProvider {
/// 只为读标签取文件头部,绝不整文件下载。
/// 之前用 dio.download 会把整首歌拉到临时目录,和 mpv 的流式播放抢带宽,
/// 20Mbps 的 FRP 链路上尤其明显(表现为流量很高但缓冲一直为 0)。
static const int _maxBytes = 2 * 1024 * 1024;
/// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。
static CancelToken? _activeToken;
final Dio _dio = Dio(); final Dio _dio = Dio();
@override @override
Future<File?> getFile(String url) async { Future<File?> getFile(String url) async {
try { try {
// 1. 获取缓存目录
final cacheDir = await getTemporaryDirectory(); final cacheDir = await getTemporaryDirectory();
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp'; final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.part';
final cacheFile = File(cachePath); final cacheFile = File(cachePath);
// 2. 如果缓存存在且未过期,直接返回 // 1. 命中本地缓存(头部文件同样按 24h 过期)
if (await cacheFile.exists()) { if (await cacheFile.exists()) {
final stat = await cacheFile.stat(); final stat = await cacheFile.stat();
// 缓存 1 小时内有效(86400 秒) if (stat.size > 0 &&
if (DateTime.now().difference(stat.modified).inSeconds < 86400) { DateTime.now().difference(stat.modified).inSeconds < 86400) {
return cacheFile; return cacheFile;
} }
} }
// 3. 获取认证头 // 2. 认证头
final headers = await WebDAVService.instance.getAuthHeaders(); final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) return null; if (headers.isEmpty) return null;
// 4. 只下载前 1MB // 3. 取消上一次未完成的下载
await _dio.download( _activeToken?.cancel('superseded');
final token = CancelToken();
_activeToken = token;
// 4. 只请求前 2MB
final response = await _dio.get<ResponseBody>(
url, url,
cachePath, cancelToken: token,
options: Options( options: Options(
headers: headers, headers: {
receiveTimeout: const Duration(seconds: 10), ...headers,
'Range': 'bytes=0-${_maxBytes - 1}',
},
responseType: ResponseType.stream,
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 10), sendTimeout: const Duration(seconds: 10),
followRedirects: true,
), ),
); );
final body = response.data;
if (body == null) return null;
// 5. 写入临时文件(最多 _maxBytes)
final sink = cacheFile.openWrite();
var written = 0;
try {
await for (final chunk in body.stream) {
final remain = _maxBytes - written;
if (remain <= 0) break;
final data =
chunk.lengthInBytes > remain ? chunk.sublist(0, remain) : chunk;
sink.add(data);
written += data.lengthInBytes;
if (written >= _maxBytes) break;
}
} finally {
await sink.flush();
await sink.close();
}
if (written == 0) {
try {
await cacheFile.delete();
} catch (_) {}
return null;
}
debugPrint(
'📦 [WebDAVFileProvider] 取头部 ${(written / 1024).toStringAsFixed(0)}KB'
'(上限 ${_maxBytes ~/ 1024}KB,不再整文件下载)');
return cacheFile; return cacheFile;
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
return null;
}
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null;
} catch (e) { } catch (e) {
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e'); debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
return null; return null;
} }
} }
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
static void cancelActive() {
_activeToken?.cancel('superseded');
_activeToken = null;
}
} }
class LocalFileProvider implements FileProvider { class LocalFileProvider implements FileProvider {
+14 -12
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import '../services/playback_service.dart';
import '../widgets/magnetic_scroll_physics.dart'; import '../widgets/magnetic_scroll_physics.dart';
import 'webdav_setup_page.dart'; import 'webdav_setup_page.dart';
import 'webdav_file_list_page.dart'; import 'webdav_file_list_page.dart';
@@ -246,16 +245,10 @@ class _HomePageState extends BaseState<HomePage> {
void _playSong(SongItem song) async { void _playSong(SongItem song) async {
try { try {
final url = WebDAVService.instance.getFileUrl(song.path); // 先校验登录,再开始播放。
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
final audioService = context.read<AudioService>(); // 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
audioService.playSong(Song( // 表现就是"媒体加载了但没有声音"。
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
final headers = await WebDAVService.instance.getAuthHeaders(); final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) { if (headers.isEmpty) {
@@ -270,7 +263,16 @@ class _HomePageState extends BaseState<HomePage> {
return; return;
} }
await PlaybackService().play(url, headers: headers); if (!mounted) return;
final url = WebDAVService.instance.getFileUrl(song.path);
final audioService = context.read<AudioService>();
await audioService.playSong(Song(
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
+104 -20
View File
@@ -81,6 +81,18 @@ class AudioService extends ChangeNotifier {
bool _hasStartedCurrentPlayback = false; bool _hasStartedCurrentPlayback = false;
bool _isUserSeeking = false; bool _isUserSeeking = false;
/// 切歌请求序号:只有最新一次请求有权解锁、写状态。
int _switchEpoch = 0;
/// 缓冲卡死看门狗
static const Duration _stallThreshold = Duration(seconds: 30);
Timer? _stallTimer;
DateTime? _lastStallRecoveryAt;
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
String? _lastPlaybackError;
String? get lastPlaybackError => _lastPlaybackError;
int _playbackGeneration = 0; int _playbackGeneration = 0;
// ---- 待恢复的播放进度 ---- // ---- 待恢复的播放进度 ----
@@ -253,16 +265,14 @@ class AudioService extends ChangeNotifier {
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
Future<void> _playCurrent() async { Future<void> _playCurrent() async {
if (_isChangingTrack) {
debugPrint('⚠️ [AudioService] track switch already running');
return;
}
if (_currentIndex < 0 || _currentIndex >= _queue.length) { if (_currentIndex < 0 || _currentIndex >= _queue.length) {
stopPlay(); stopPlay();
return; return;
} }
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
final epoch = ++_switchEpoch;
_isChangingTrack = true; _isChangingTrack = true;
_handlingCompletion = false; _handlingCompletion = false;
_hasStartedCurrentPlayback = false; _hasStartedCurrentPlayback = false;
@@ -283,9 +293,29 @@ class AudioService extends ChangeNotifier {
return; return;
} }
await _playWithHeaders(song.url!); try {
await _playWithHeaders(song.url!);
} catch (e) {
// 播放失败(超时/401/网络中断):必须走到 finally 解锁,
// 并把错误记下来,供上层提示或下一首重试。
_lastPlaybackError = '播放失败: $e';
debugPrint('❌ [AudioService] play failed: $e');
notifyListeners();
return;
}
if (epoch != _switchEpoch) {
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
return;
}
await _waitForPlaybackStarted(); await _waitForPlaybackStarted();
if (epoch != _switchEpoch) {
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
return;
}
// ⭐ 如果有待恢复的进度,执行 seek // ⭐ 如果有待恢复的进度,执行 seek
if (_pendingSeekPosition != null && if (_pendingSeekPosition != null &&
_pendingSeekPosition!.inMilliseconds > 0) { _pendingSeekPosition!.inMilliseconds > 0) {
@@ -299,6 +329,7 @@ class AudioService extends ChangeNotifier {
_syncPlayerStateDelayed(); _syncPlayerStateDelayed();
if (_hasStartedCurrentPlayback) { if (_hasStartedCurrentPlayback) {
_lastPlaybackError = null;
_onSongChanged?.call(song); _onSongChanged?.call(song);
_loadMetadataForCurrentSong(generation); _loadMetadataForCurrentSong(generation);
} }
@@ -306,12 +337,51 @@ class AudioService extends ChangeNotifier {
// 切歌完成后保存状态 // 切歌完成后保存状态
await savePlaybackState(); await savePlaybackState();
} finally { } finally {
_isChangingTrack = false; // 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
if (epoch == _switchEpoch) {
_isChangingTrack = false;
}
} }
} }
Future<void> _waitForPlaybackStarted() async { /// 缓冲卡死自愈:重开当前歌曲并 seek 回原位置。
final player = PlaybackService().player; ///
/// 触发条件:mpv 的 buffering 持续为 true 超过 [_stallThreshold]。
/// 两次自动恢复之间至少间隔 2 分钟,避免源站长时间不可用时反复重连。
Future<void> _recoverFromStall() 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 (_lastStallRecoveryAt != null &&
now.difference(_lastStallRecoveryAt!) < const Duration(minutes: 2)) {
debugPrint('🩺 [AudioService] 仍在缓冲,但 2 分钟内已恢复过,交给用户手动切歌');
return;
}
_lastStallRecoveryAt = now;
final resumeAt = positionNotifier.value;
debugPrint(
'🩺 [AudioService] 缓冲超过 ${_stallThreshold.inSeconds}s,尝试重连:${song.title}');
try {
await _playWithHeaders(url);
if (resumeAt > Duration.zero) {
await PlaybackService().seek(resumeAt);
positionNotifier.value = resumeAt;
}
debugPrint('✅ [AudioService] 自动恢复完成');
} catch (e) {
debugPrint('❌ [AudioService] 自动恢复失败: $e');
_lastPlaybackError = '缓冲卡住,自动恢复失败';
notifyListeners();
}
}
Future<void> _waitForPlaybackStarted() async { final player = PlaybackService().player;
if (player.state.playing) { if (player.state.playing) {
_hasStartedCurrentPlayback = true; _hasStartedCurrentPlayback = true;
@@ -493,11 +563,7 @@ class AudioService extends ChangeNotifier {
return; return;
} }
if (_isChangingTrack) { // 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
debugPrint('⚠️ [next] REJECTED: track switch in progress');
return;
}
if (_playMode == PlayMode.shuffle) { if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return; if (_shuffledIndices.isEmpty) return;
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length; final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
@@ -515,12 +581,7 @@ class AudioService extends ChangeNotifier {
Future<void> previous() async { Future<void> previous() async {
if (_queue.isEmpty) return; if (_queue.isEmpty) return;
if (_isChangingTrack) { // 同 next():最新请求胜出,不拒绝
debugPrint(
'⚠️ [AudioService] previous() ignored: track switch in progress');
return;
}
if (_playMode == PlayMode.shuffle) { if (_playMode == PlayMode.shuffle) {
if (_shuffledIndices.isEmpty) return; if (_shuffledIndices.isEmpty) return;
final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length; final prevIdx = (_shuffledIndex - 1) % _shuffledIndices.length;
@@ -664,6 +725,27 @@ class AudioService extends ChangeNotifier {
}), }),
); );
// mpv 的错误(401 / 连接失败 / 解码失败)以前没有任何订阅,失败是静默的
_subscriptions.add(
player.stream.error.listen((error) {
debugPrint('❌ [AudioService] player error: $error');
_lastPlaybackError = error;
notifyListeners();
}),
);
// 缓冲状态:定位"流量很高但缓冲为 0"时要靠它
_subscriptions.add(
player.stream.buffering.listen((buffering) {
debugPrint('🌊 [AudioService] buffering=$buffering');
_stallTimer?.cancel();
_stallTimer = null;
if (!buffering) return;
// 持续缓冲超过阈值:多半是网络卡死,主动重连一次当前歌曲
_stallTimer = Timer(_stallThreshold, _recoverFromStall);
}),
);
// ⭐ 完整生命周期校验的 completed 监听 // ⭐ 完整生命周期校验的 completed 监听
_subscriptions.add( _subscriptions.add(
player.stream.completed.listen((_) { player.stream.completed.listen((_) {
@@ -885,6 +967,8 @@ class AudioService extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
// 先保存再清理 // 先保存再清理
_stallTimer?.cancel();
_stallTimer = null;
savePlaybackState(); savePlaybackState();
_stopListening(); _stopListening();
positionNotifier.dispose(); positionNotifier.dispose();
+41 -7
View File
@@ -1,4 +1,6 @@
// lib/services/playback_service.dart // lib/services/playback_service.dart
import 'dart:async';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -11,6 +13,12 @@ class PlaybackService {
debugPrint('🎯 [PlaybackService] 单例创建, serviceId=$_serviceId'); debugPrint('🎯 [PlaybackService] 单例创建, serviceId=$_serviceId');
} }
/// 弱网/源站抖动时,mpv 的 open 可能长时间不返回。
/// 不设上限的话 _playCurrent() 的 finally 永远不执行,
/// _isChangingTrack 会永久为 true,整个切歌链路假死。
static const Duration _openTimeout = Duration(seconds: 20);
static const Duration _controlTimeout = Duration(seconds: 5);
Player? _player; Player? _player;
bool _initialized = false; bool _initialized = false;
late final String _serviceId; // ⭐ 服务实例 ID late final String _serviceId; // ⭐ 服务实例 ID
@@ -41,15 +49,41 @@ class PlaybackService {
// ---------- 播放 ---------- // ---------- 播放 ----------
Future<void> play(String url, {Map<String, String>? headers}) async { Future<void> play(String url, {Map<String, String>? headers}) async {
debugPrint( debugPrint(
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}'); '▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}, url=${_shortUrl(url)}');
if (!_initialized) init(); if (!_initialized) init();
final media = (headers != null && headers.isNotEmpty) final media = (headers != null && headers.isNotEmpty)
? Media(url, httpHeaders: headers) ? Media(url, httpHeaders: headers)
: Media(url); : Media(url);
await _player!.open(media); try {
await _player!.play(); await _player!.open(media).timeout(_openTimeout);
await _player!.play().timeout(_controlTimeout);
} on TimeoutException {
debugPrint(
'❌ [PlaybackService] open timeout (${_openTimeout.inSeconds}s): ${_shortUrl(url)}');
await _safeStop();
rethrow;
} catch (e) {
debugPrint('❌ [PlaybackService] play failed: $e');
await _safeStop();
rethrow;
}
}
/// 失败时尽量把播放器拉回可用的空闲状态,避免下一次 open 继续卡住。
Future<void> _safeStop() async {
try {
await _player?.stop().timeout(const Duration(seconds: 3));
} catch (_) {
// 忽略:stop 本身失败也不应影响上层解锁
}
}
/// 日志里不打完整 URL(WebDAV 路径很长,且可能带签名参数)
String _shortUrl(String url) {
final hash = url.hashCode.toRadixString(16);
return url.length <= 48 ? url : '${url.substring(0, 48)}…#$hash';
} }
// ---------- 控制 ---------- // ---------- 控制 ----------
@@ -57,7 +91,7 @@ class PlaybackService {
debugPrint( debugPrint(
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}'); '⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.pause(); await _player!.pause().timeout(_controlTimeout);
} }
} }
@@ -65,7 +99,7 @@ class PlaybackService {
debugPrint( debugPrint(
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}'); '▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.play(); await _player!.play().timeout(_controlTimeout);
} }
} }
@@ -73,7 +107,7 @@ class PlaybackService {
debugPrint( debugPrint(
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}'); '⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.stop(); await _player!.stop().timeout(_controlTimeout);
} }
} }
@@ -81,7 +115,7 @@ class PlaybackService {
debugPrint( debugPrint(
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}'); '⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
if (_initialized && _player != null) { if (_initialized && _player != null) {
await _player!.seek(position); await _player!.seek(position).timeout(_controlTimeout);
} }
} }