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 {
|
||||
|
||||
Reference in New Issue
Block a user