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:
@@ -8,6 +8,7 @@ import 'package:media_kit/media_kit.dart' as media_kit;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'playback_service.dart';
|
import 'playback_service.dart';
|
||||||
import 'network_watcher.dart';
|
import 'network_watcher.dart';
|
||||||
|
import 'prefetch_service.dart';
|
||||||
import '../metadata/metadata_service.dart';
|
import '../metadata/metadata_service.dart';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
@@ -107,6 +108,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||||
DateTime? _trackStartAt;
|
DateTime? _trackStartAt;
|
||||||
DateTime? _playReadyAt;
|
DateTime? _playReadyAt;
|
||||||
|
int _prefetchedGeneration = -1;
|
||||||
bool _firstAudioReported = false;
|
bool _firstAudioReported = false;
|
||||||
|
|
||||||
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
|
/// 最近一次播放失败的原因,供 UI 提示;成功播放后自动清空。
|
||||||
@@ -418,6 +420,39 @@ class AudioService extends ChangeNotifier {
|
|||||||
}();
|
}();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 播到 60% 时预热下一首(HEAD + Range 0-0,不下载正文)。
|
||||||
|
///
|
||||||
|
/// 日志实测起播延迟 2~3s 都在"等首批数据",提前完成 DNS/TLS/反代握手
|
||||||
|
/// 可以把这段时间压掉一部分;失败完全静默,不影响当前播放。
|
||||||
|
void _maybePrefetchNext(Duration position) {
|
||||||
|
final duration = durationNotifier.value;
|
||||||
|
if (duration.inSeconds < 60) return;
|
||||||
|
if (position.inMilliseconds < duration.inMilliseconds * 0.6) return;
|
||||||
|
if (_prefetchedGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
|
final next = _peekNextSong();
|
||||||
|
final url = next?.url;
|
||||||
|
if (url == null || url.isEmpty) return;
|
||||||
|
|
||||||
|
_prefetchedGeneration = _playbackGeneration;
|
||||||
|
debugPrint('🔥 [AudioService] 预热下一首:${next!.title}');
|
||||||
|
PrefetchService().warm(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只推算下一首,不改变队列/索引(next() 会改)。
|
||||||
|
Song? _peekNextSong() {
|
||||||
|
if (_queue.isEmpty) return null;
|
||||||
|
|
||||||
|
if (_playMode == PlayMode.shuffle) {
|
||||||
|
if (_shuffledIndices.isEmpty) return null;
|
||||||
|
final nextIdx = (_shuffledIndex + 1) % _shuffledIndices.length;
|
||||||
|
return _queue[_shuffledIndices[nextIdx]];
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextIdx = (_currentIndex + 1) % _queue.length;
|
||||||
|
return _queue[nextIdx];
|
||||||
|
}
|
||||||
|
|
||||||
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
|
/// 进度看门狗:播放中进度 30s 不动、且近期出现过缓冲,判定为卡死。
|
||||||
///
|
///
|
||||||
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
|
/// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动,
|
||||||
@@ -861,6 +896,7 @@ class AudioService extends ChangeNotifier {
|
|||||||
_playReadyAt = null;
|
_playReadyAt = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_maybePrefetchNext(position);
|
||||||
positionNotifier.value = position;
|
positionNotifier.value = position;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user