diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index a8774da..6ad9b41 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -8,6 +8,7 @@ import 'package:media_kit/media_kit.dart' as media_kit; import 'package:path_provider/path_provider.dart'; import 'playback_service.dart'; import 'network_watcher.dart'; +import 'prefetch_service.dart'; import '../metadata/metadata_service.dart'; import '../database/song_database.dart'; import '../utils/artwork_helper.dart'; @@ -107,6 +108,7 @@ class AudioService extends ChangeNotifier { /// 也不发 buffering / error(实测公网链路可静默卡 75s+)。 DateTime? _trackStartAt; DateTime? _playReadyAt; + int _prefetchedGeneration = -1; bool _firstAudioReported = false; /// 最近一次播放失败的原因,供 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 不动、且近期出现过缓冲,判定为卡死。 /// /// 为什么不能只看 `buffering`:弱网下它会在 true/false 之间反复抖动, @@ -861,6 +896,7 @@ class AudioService extends ChangeNotifier { _playReadyAt = null; } } + _maybePrefetchNext(position); positionNotifier.value = position; }), ); diff --git a/lib/services/prefetch_service.dart b/lib/services/prefetch_service.dart new file mode 100644 index 0000000..d0bfe8b --- /dev/null +++ b/lib/services/prefetch_service.dart @@ -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 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; + } +}