From 9e58d5267850ac5842c0efcbb195d42cb2f463e6 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Fri, 23 Oct 2026 21:04:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(playback):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=AD=8C=E6=B0=B8=E4=B9=85=E5=81=87=E6=AD=BB=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=AE=A9=E5=85=83=E6=95=B0=E6=8D=AE=E6=8A=93=E5=8F=96=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=A2=E6=92=AD=E6=94=BE=E5=B8=A6=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启): 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。 真机验证待设备重新连接后进行。 --- lib/metadata/file_provider.dart | 85 +++++++++++++++++--- lib/pages/home_page.dart | 26 +++--- lib/services/audio_service.dart | 124 ++++++++++++++++++++++++----- lib/services/playback_service.dart | 48 +++++++++-- 4 files changed, 232 insertions(+), 51 deletions(-) diff --git a/lib/metadata/file_provider.dart b/lib/metadata/file_provider.dart index 428aafe..b272433 100644 --- a/lib/metadata/file_provider.dart +++ b/lib/metadata/file_provider.dart @@ -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 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( 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 { diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 66bd148..7bd4341 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -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 { void _playSong(SongItem song) async { try { - final url = WebDAVService.instance.getFileUrl(song.path); - - final audioService = context.read(); - 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 { return; } - await PlaybackService().play(url, headers: headers); + if (!mounted) return; + + final url = WebDAVService.instance.getFileUrl(song.path); + final audioService = context.read(); + await audioService.playSong(Song( + id: song.path, + title: song.displayTitle, + artist: song.displaySubtitle, + url: url, + )); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 56c1ccf..c1e1820 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -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 _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 _waitForPlaybackStarted() async { - final player = PlaybackService().player; + /// 缓冲卡死自愈:重开当前歌曲并 seek 回原位置。 + /// + /// 触发条件:mpv 的 buffering 持续为 true 超过 [_stallThreshold]。 + /// 两次自动恢复之间至少间隔 2 分钟,避免源站长时间不可用时反复重连。 + Future _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 _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 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(); diff --git a/lib/services/playback_service.dart b/lib/services/playback_service.dart index b5e160f..57d1a34 100644 --- a/lib/services/playback_service.dart +++ b/lib/services/playback_service.dart @@ -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 play(String url, {Map? 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 _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); } }