import 'dart:convert'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import '../../models/video_item.dart'; import 'local_media_server.dart'; /// 已下载视频元数据(持久化在 downloads//meta.json) class DownloadItem { DownloadItem({ required this.videoId, required this.title, required this.coverUrl, required this.duration, required this.sizeBytes, required this.mp4, required this.createdAt, }); final int videoId; final String title; final String coverUrl; final int duration; // 秒 final int sizeBytes; final bool mp4; final int createdAt; // 毫秒时间戳 /// 本地播放地址(经 LocalMediaServer /local 路由,支持 Range) String get playUrl => LocalMediaServer.I.localPlayUrl(videoId, mp4: mp4); Map toJson() => { 'video_id': videoId, 'title': title, 'cover_url': coverUrl, 'duration': duration, 'size_bytes': sizeBytes, 'mp4': mp4, 'created_at': createdAt, }; factory DownloadItem.fromJson(Map j) => DownloadItem( videoId: j['video_id'] as int, title: (j['title'] as String?) ?? '', coverUrl: (j['cover_url'] as String?) ?? '', duration: (j['duration'] as num?)?.toInt() ?? 0, sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0, mp4: j['mp4'] as bool? ?? false, createdAt: (j['created_at'] as num?)?.toInt() ?? 0, ); } /// 下载任务状态机:value 为 0..1 进度;done/error 用独立集合 class DownloadsState { const DownloadsState({ this.progress = const {}, this.errors = const {}, this.items = const [], }); final Map progress; // 下载中 final Map errors; // 失败信息 final List items; // 已完成(按下载时间倒序) DownloadsState copyWith({ Map? progress, Map? errors, List? items, }) => DownloadsState( progress: progress ?? this.progress, errors: errors ?? this.errors, items: items ?? this.items, ); } class DownloadManager extends StateNotifier { DownloadManager() : super(const DownloadsState()); final Dio _dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(minutes: 10), headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36', }, )); final Map _cancel = {}; bool isDownloaded(int videoId) => state.items.any((e) => e.videoId == videoId); bool isDownloading(int videoId) => state.progress.containsKey(videoId); /// 启动时扫描下载目录恢复列表 Future init() async { final dir = LocalMediaServer.I.downloadsDir; final items = []; await for (final e in dir.list()) { if (e is! Directory) continue; final meta = File(p.join(e.path, 'meta.json')); if (!await meta.exists()) continue; try { items.add(DownloadItem.fromJson( jsonDecode(await meta.readAsString()) as Map)); } catch (_) {} } items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); state = state.copyWith(items: items); } /// 开始下载(已在下载/已下载则忽略) Future start(VideoItem item) async { if (isDownloading(item.videoId) || isDownloaded(item.videoId)) return; final dir = Directory(p.join( LocalMediaServer.I.downloadsDir.path, '${item.videoId}')); await dir.create(recursive: true); final cancel = CancelToken(); _cancel[item.videoId] = cancel; _setProgress(item.videoId, 0); try { final isMp4 = item.videoUrl.contains('.mp4') && !item.videoUrl.contains('m3u8'); int size; if (isMp4) { size = await _downloadMp4(item.videoUrl, dir, item.videoId, cancel); } else { size = await _downloadHls(item.videoUrl, dir, item.videoId, cancel); } final meta = DownloadItem( videoId: item.videoId, title: item.title, coverUrl: item.coverUrl, duration: item.duration, sizeBytes: size, mp4: isMp4, createdAt: DateTime.now().millisecondsSinceEpoch, ); await File(p.join(dir.path, 'meta.json')) .writeAsString(jsonEncode(meta.toJson())); final progress = Map.from(state.progress) ..remove(item.videoId); state = state.copyWith( progress: progress, items: [meta, ...state.items], ); } catch (e) { // 失败/取消:清掉半成品目录 try { await dir.delete(recursive: true); } catch (_) {} final progress = Map.from(state.progress) ..remove(item.videoId); if (e is DioException && CancelToken.isCancel(e)) { state = state.copyWith(progress: progress); } else { final errors = Map.from(state.errors) ..[item.videoId] = '下载失败,请重试'; state = state.copyWith(progress: progress, errors: errors); } rethrow; } finally { _cancel.remove(item.videoId); } } void cancel(int videoId) => _cancel[videoId]?.cancel(); Future delete(int videoId) async { _cancel[videoId]?.cancel(); final dir = Directory( p.join(LocalMediaServer.I.downloadsDir.path, '$videoId')); try { await dir.delete(recursive: true); } catch (_) {} state = state.copyWith( items: state.items.where((e) => e.videoId != videoId).toList(), ); } // ---------- MP4 ---------- Future _downloadMp4( String url, Directory dir, int videoId, CancelToken cancel) async { final file = File(p.join(dir.path, 'video.mp4')); await _dio.download( url, file.path, cancelToken: cancel, onReceiveProgress: (r, t) { if (t > 0) _setProgress(videoId, r / t); }, ); return file.length(); } // ---------- HLS ---------- /// 拉取 m3u8 → 下载 key + 全部 ts → 改写为本地相对引用 Future _downloadHls( String url, Directory dir, int videoId, CancelToken cancel) async { var playlist = (await _dio.get(url, cancelToken: cancel)).data ?? ''; var base = Uri.parse(url); // master playlist(多变体):取第一个变体再拉一次 if (playlist.contains('#EXT-X-STREAM-INF')) { final variant = const LineSplitter() .convert(playlist) .firstWhere((l) => l.trim().isNotEmpty && !l.startsWith('#'), orElse: () => ''); if (variant.isEmpty) throw Exception('空变体列表'); final variantUrl = base.resolve(variant.trim()).toString(); playlist = (await _dio.get(variantUrl, cancelToken: cancel)).data ?? ''; base = Uri.parse(variantUrl); } final lines = const LineSplitter().convert(playlist); final segUrls = [ for (final l in lines) if (l.trim().isNotEmpty && !l.trim().startsWith('#')) base.resolve(l.trim()), ]; if (segUrls.isEmpty) throw Exception('无分片'); var total = 0; var done = 0; // key(可能多个,逐标签改写时按出现顺序对应) final keyUris = []; for (final l in lines) { for (final m in RegExp(r'URI="([^"]+)"').allMatches(l)) { final abs = base.resolve(m.group(1)!).toString(); if (!keyUris.contains(abs)) keyUris.add(abs); } } final keyNames = {}; for (var i = 0; i < keyUris.length; i++) { final name = i == 0 ? 'key.bin' : 'key_$i.bin'; final bytes = (await _dio.get>(keyUris[i], cancelToken: cancel, options: Options(responseType: ResponseType.bytes))) .data; if (bytes == null || bytes.isEmpty) throw Exception('密钥下载失败'); await File(p.join(dir.path, name)).writeAsBytes(bytes); keyNames[keyUris[i]] = name; total += bytes.length; } // ts 分片:4 并发 const concurrency = 4; for (var i = 0; i < segUrls.length; i += concurrency) { final batch = segUrls.sublist( i, (i + concurrency).clamp(0, segUrls.length)); final results = await Future.wait(batch.map((u) => _dio.get>( u.toString(), cancelToken: cancel, options: Options(responseType: ResponseType.bytes)))); for (var j = 0; j < batch.length; j++) { final bytes = results[j].data; if (bytes == null || bytes.isEmpty) throw Exception('分片下载失败'); await File(p.join(dir.path, 'seg_${i + j}.ts')).writeAsBytes(bytes); total += bytes.length; done++; } _setProgress(videoId, done / segUrls.length); } // 改写 playlist 为本地相对引用 var segIndex = 0; final out = StringBuffer(); for (final raw in lines) { final line = raw.trim(); if (line.isEmpty) continue; if (line.startsWith('#')) { out.writeln(line.replaceAllMapped(RegExp(r'URI="([^"]+)"'), (m) { final abs = base.resolve(m.group(1)!).toString(); return 'URI="${keyNames[abs] ?? m.group(1)!}"'; })); } else { out.writeln('seg_${segIndex++}.ts'); } } await File(p.join(dir.path, 'index.m3u8')).writeAsString(out.toString()); return total; } void _setProgress(int videoId, double value) { final progress = Map.from(state.progress); progress[videoId] = value; state = state.copyWith(progress: progress); } } final downloadManagerProvider = StateNotifierProvider( (ref) => DownloadManager());