feat(playback): 下一首预热(60% 时 HEAD 预握手)

日志实测:open 完成仅 37~60ms,但起播耗时 2.1~2.8s,
说明等待发生在 mpv 拿首批数据,而不是建连。

- 新增 PrefetchService:HEAD + Range 0-0,只走 DNS/TLS/反代,
  不下载正文;失败静默,不影响播放
- AudioService 在进度达到 60%(且时长 > 60s)时预热下一首,
  每首歌只触发一次;shuffle 模式下按当前随机索引推算

待验证:播到 60% 时应出现
  🔥 [AudioService] 预热下一首:xxx
  🔥 [Prefetch] 预热完成 xxxms,status=200/206
对比下一首的 "起播耗时" 是否下降。
This commit is contained in:
2026-10-23 23:06:18 +08:00
parent 111369c20c
commit 2d571eaae4
2 changed files with 104 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
// lib/services/prefetch_service.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'webdav_service.dart';
/// 下一首预热。
///
/// 目的:在用户真正切歌之前,提前完成 DNS 解析、TLS 握手、反代/CDN 的首包,
/// 让 mpv 起播时少等一会儿。
///
/// 只发 HEAD + `Range: bytes=0-0`,不下载正文,带宽开销可以忽略;
/// 失败也完全静默,不影响当前播放。
class PrefetchService {
static final PrefetchService _instance = PrefetchService._internal();
factory PrefetchService() => _instance;
PrefetchService._internal();
final Dio _dio = Dio();
String? _lastUrl;
bool _busy = false;
Future<void> warm(String url) async {
if (url.isEmpty) return;
if (_busy) return;
if (_lastUrl == url) return;
_busy = true;
_lastUrl = url;
final sw = Stopwatch()..start();
try {
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) return;
final response = await _dio.head(
url,
options: Options(
headers: {
...headers,
'Range': 'bytes=0-0',
},
receiveTimeout: const Duration(seconds: 8),
sendTimeout: const Duration(seconds: 5),
// HEAD 返回 4xx/5xx 也不抛异常,交给日志记录
validateStatus: (_) => true,
),
);
debugPrint('🔥 [Prefetch] 预热完成 ${sw.elapsedMilliseconds}ms,'
'status=${response.statusCode}');
} catch (e) {
debugPrint('⚠️ [Prefetch] 预热失败(忽略): $e');
} finally {
_busy = false;
// 允许同一首歌在下一轮重新预热(比如重播)
if (_lastUrl == url) {
// 保留 _lastUrl 做去重,下一次换歌时会被覆盖
}
}
}
/// 切歌/停止时清掉去重状态
void reset() {
_lastUrl = null;
}
}