问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启):
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。
真机验证待设备重新连接后进行。
127 lines
3.8 KiB
Dart
127 lines
3.8 KiB
Dart
// lib/metadata/file_provider.dart
|
|
import 'dart:io';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:dio/dio.dart';
|
|
import '../services/webdav_service.dart';
|
|
import '../utils/song_key.dart';
|
|
|
|
abstract class FileProvider {
|
|
Future<File?> getFile(String url);
|
|
}
|
|
|
|
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 {
|
|
final cacheDir = await getTemporaryDirectory();
|
|
final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}.part';
|
|
final cacheFile = File(cachePath);
|
|
|
|
// 1. 命中本地缓存(头部文件同样按 24h 过期)
|
|
if (await cacheFile.exists()) {
|
|
final stat = await cacheFile.stat();
|
|
if (stat.size > 0 &&
|
|
DateTime.now().difference(stat.modified).inSeconds < 86400) {
|
|
return cacheFile;
|
|
}
|
|
}
|
|
|
|
// 2. 认证头
|
|
final headers = await WebDAVService.instance.getAuthHeaders();
|
|
if (headers.isEmpty) return null;
|
|
|
|
// 3. 取消上一次未完成的下载
|
|
_activeToken?.cancel('superseded');
|
|
final token = CancelToken();
|
|
_activeToken = token;
|
|
|
|
// 4. 只请求前 2MB
|
|
final response = await _dio.get<ResponseBody>(
|
|
url,
|
|
cancelToken: token,
|
|
options: Options(
|
|
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 {
|
|
@override
|
|
Future<File?> getFile(String url) async {
|
|
final file = File(url);
|
|
if (await file.exists()) {
|
|
return file;
|
|
}
|
|
return null;
|
|
}
|
|
}
|