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:
@@ -1,6 +1,6 @@
|
||||
// lib/metadata/file_provider.dart
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart'; // ⭐ 添加
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../services/webdav_service.dart';
|
||||
@@ -11,46 +11,107 @@ abstract class 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();
|
||||
|
||||
@override
|
||||
Future<File?> getFile(String url) async {
|
||||
try {
|
||||
// 1. 获取缓存目录
|
||||
final cacheDir = await getTemporaryDirectory();
|
||||
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.tmp';
|
||||
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.part';
|
||||
final cacheFile = File(cachePath);
|
||||
|
||||
// 2. 如果缓存存在且未过期,直接返回
|
||||
// 1. 命中本地缓存(头部文件同样按 24h 过期)
|
||||
if (await cacheFile.exists()) {
|
||||
final stat = await cacheFile.stat();
|
||||
// 缓存 1 小时内有效(86400 秒)
|
||||
if (DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
||||
if (stat.size > 0 &&
|
||||
DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
||||
return cacheFile;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取认证头
|
||||
// 2. 认证头
|
||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||
if (headers.isEmpty) return null;
|
||||
|
||||
// 4. 只下载前 1MB
|
||||
await _dio.download(
|
||||
// 3. 取消上一次未完成的下载
|
||||
_activeToken?.cancel('superseded');
|
||||
final token = CancelToken();
|
||||
_activeToken = token;
|
||||
|
||||
// 4. 只请求前 2MB
|
||||
final response = await _dio.get<ResponseBody>(
|
||||
url,
|
||||
cachePath,
|
||||
cancelToken: token,
|
||||
options: Options(
|
||||
headers: headers,
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {
|
||||
...headers,
|
||||
'Range': 'bytes=0-${_maxBytes - 1}',
|
||||
},
|
||||
responseType: ResponseType.stream,
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
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;
|
||||
} on DioException catch (e) {
|
||||
if (CancelToken.isCancel(e)) {
|
||||
debugPrint('↩️ [WebDAVFileProvider] 下载已取消(切歌触发)');
|
||||
return null;
|
||||
}
|
||||
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [WebDAVFileProvider] download failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动取消进行中的元数据下载(切歌时可由上层调用)。
|
||||
static void cancelActive() {
|
||||
_activeToken?.cancel('superseded');
|
||||
_activeToken = null;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalFileProvider implements FileProvider {
|
||||
|
||||
+14
-12
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/audio_service.dart';
|
||||
import '../services/webdav_service.dart';
|
||||
import '../services/playback_service.dart';
|
||||
import '../widgets/magnetic_scroll_physics.dart';
|
||||
import 'webdav_setup_page.dart';
|
||||
import 'webdav_file_list_page.dart';
|
||||
@@ -246,16 +245,10 @@ class _HomePageState extends BaseState<HomePage> {
|
||||
|
||||
void _playSong(SongItem song) async {
|
||||
try {
|
||||
final url = WebDAVService.instance.getFileUrl(song.path);
|
||||
|
||||
final audioService = context.read<AudioService>();
|
||||
audioService.playSong(Song(
|
||||
id: song.path,
|
||||
title: song.displayTitle,
|
||||
artist: song.displaySubtitle,
|
||||
url: url,
|
||||
));
|
||||
|
||||
// 先校验登录,再开始播放。
|
||||
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
|
||||
// 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
|
||||
// 表现就是"媒体加载了但没有声音"。
|
||||
final headers = await WebDAVService.instance.getAuthHeaders();
|
||||
|
||||
if (headers.isEmpty) {
|
||||
@@ -270,7 +263,16 @@ class _HomePageState extends BaseState<HomePage> {
|
||||
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) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
+104
-20
@@ -81,6 +81,18 @@ class AudioService extends ChangeNotifier {
|
||||
bool _hasStartedCurrentPlayback = 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;
|
||||
|
||||
// ---- 待恢复的播放进度 ----
|
||||
@@ -253,16 +265,14 @@ class AudioService extends ChangeNotifier {
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
Future<void> _playCurrent() async {
|
||||
if (_isChangingTrack) {
|
||||
debugPrint('⚠️ [AudioService] track switch already running');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex < 0 || _currentIndex >= _queue.length) {
|
||||
stopPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
||||
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
||||
final epoch = ++_switchEpoch;
|
||||
_isChangingTrack = true;
|
||||
_handlingCompletion = false;
|
||||
_hasStartedCurrentPlayback = false;
|
||||
@@ -283,9 +293,29 @@ class AudioService extends ChangeNotifier {
|
||||
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();
|
||||
|
||||
if (epoch != _switchEpoch) {
|
||||
debugPrint('↩️ [AudioService] 旧请求已被新的切歌取代,停止后续处理');
|
||||
return;
|
||||
}
|
||||
|
||||
// ⭐ 如果有待恢复的进度,执行 seek
|
||||
if (_pendingSeekPosition != null &&
|
||||
_pendingSeekPosition!.inMilliseconds > 0) {
|
||||
@@ -299,6 +329,7 @@ class AudioService extends ChangeNotifier {
|
||||
_syncPlayerStateDelayed();
|
||||
|
||||
if (_hasStartedCurrentPlayback) {
|
||||
_lastPlaybackError = null;
|
||||
_onSongChanged?.call(song);
|
||||
_loadMetadataForCurrentSong(generation);
|
||||
}
|
||||
@@ -306,12 +337,51 @@ class AudioService extends ChangeNotifier {
|
||||
// 切歌完成后保存状态
|
||||
await savePlaybackState();
|
||||
} finally {
|
||||
_isChangingTrack = false;
|
||||
// 只有最新的一次切歌才有资格解锁,防止旧请求把新请求的锁提前放掉
|
||||
if (epoch == _switchEpoch) {
|
||||
_isChangingTrack = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForPlaybackStarted() async {
|
||||
final player = PlaybackService().player;
|
||||
/// 缓冲卡死自愈:重开当前歌曲并 seek 回原位置。
|
||||
///
|
||||
/// 触发条件: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) {
|
||||
_hasStartedCurrentPlayback = true;
|
||||
@@ -493,11 +563,7 @@ class AudioService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isChangingTrack) {
|
||||
debugPrint('⚠️ [next] REJECTED: track switch in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// 不再因为 _isChangingTrack 拒绝:新请求会取代进行中的切歌(latest wins)
|
||||
if (_playMode == PlayMode.shuffle) {
|
||||
if (_shuffledIndices.isEmpty) return;
|
||||
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||
@@ -515,12 +581,7 @@ class AudioService extends ChangeNotifier {
|
||||
Future<void> previous() async {
|
||||
if (_queue.isEmpty) return;
|
||||
|
||||
if (_isChangingTrack) {
|
||||
debugPrint(
|
||||
'⚠️ [AudioService] previous() ignored: track switch in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
// 同 next():最新请求胜出,不拒绝
|
||||
if (_playMode == PlayMode.shuffle) {
|
||||
if (_shuffledIndices.isEmpty) return;
|
||||
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 监听
|
||||
_subscriptions.add(
|
||||
player.stream.completed.listen((_) {
|
||||
@@ -885,6 +967,8 @@ class AudioService extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
// 先保存再清理
|
||||
_stallTimer?.cancel();
|
||||
_stallTimer = null;
|
||||
savePlaybackState();
|
||||
_stopListening();
|
||||
positionNotifier.dispose();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// lib/services/playback_service.dart
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@@ -11,6 +13,12 @@ class PlaybackService {
|
||||
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;
|
||||
bool _initialized = false;
|
||||
late final String _serviceId; // ⭐ 服务实例 ID
|
||||
@@ -41,15 +49,41 @@ class PlaybackService {
|
||||
// ---------- 播放 ----------
|
||||
Future<void> play(String url, {Map<String, String>? headers}) async {
|
||||
debugPrint(
|
||||
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||
'▶️ [PlaybackService] play() called, serviceId=$_serviceId, player=${_player?.hashCode}, url=${_shortUrl(url)}');
|
||||
if (!_initialized) init();
|
||||
|
||||
final media = (headers != null && headers.isNotEmpty)
|
||||
? Media(url, httpHeaders: headers)
|
||||
: Media(url);
|
||||
|
||||
await _player!.open(media);
|
||||
await _player!.play();
|
||||
try {
|
||||
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(
|
||||
'⏸️ [PlaybackService] pause() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||
if (_initialized && _player != null) {
|
||||
await _player!.pause();
|
||||
await _player!.pause().timeout(_controlTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +99,7 @@ class PlaybackService {
|
||||
debugPrint(
|
||||
'▶️ [PlaybackService] resume() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||
if (_initialized && _player != null) {
|
||||
await _player!.play();
|
||||
await _player!.play().timeout(_controlTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +107,7 @@ class PlaybackService {
|
||||
debugPrint(
|
||||
'⏹️ [PlaybackService] stop() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||
if (_initialized && _player != null) {
|
||||
await _player!.stop();
|
||||
await _player!.stop().timeout(_controlTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +115,7 @@ class PlaybackService {
|
||||
debugPrint(
|
||||
'⏩ [PlaybackService] seek() called, serviceId=$_serviceId, player=${_player?.hashCode}');
|
||||
if (_initialized && _player != null) {
|
||||
await _player!.seek(position);
|
||||
await _player!.seek(position).timeout(_controlTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user