// 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 { /// [maxBytes] 是只取文件头部的上限(本地文件实现会忽略它)。 Future getFile(String url, {int maxBytes = 512 * 1024}); } class WebDAVFileProvider implements FileProvider { /// 只为读标签取文件头部,绝不整文件下载。 /// 之前用 dio.download 会把整首歌拉到临时目录,和 mpv 的流式播放抢带宽, /// 20Mbps 的 FRP 链路上尤其明显(表现为流量很高但缓冲一直为 0)。 /// /// 从访问日志看:2MB 的探测请求在这条链路上要 10~18 秒, /// 期间 libmpv 的 206 吞吐会被压到 1Mbps 左右,所以压到 512KB。 /// 常见标签 + 封面都在这范围内;读不到时由上层在 80% 处重试。 static const int defaultMaxBytes = 512 * 1024; /// 切歌时取消上一次未完成的下载,避免多个下载叠加占满带宽。 static CancelToken? _activeToken; final Dio _dio = Dio(); @override Future getFile(String url, {int maxBytes = defaultMaxBytes}) async { try { final cacheDir = await getTemporaryDirectory(); final cachePath = '${cacheDir.path}/metadata_${songKeyForUrl(url)}_${maxBytes ~/ 1024}k.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( 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 getFile(String url, {int maxBytes = 512 * 1024}) async { final file = File(url); if (await file.exists()) { return file; } return null; } }