1
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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/<videoId>/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<String, dynamic> toJson() => {
|
||||
'video_id': videoId,
|
||||
'title': title,
|
||||
'cover_url': coverUrl,
|
||||
'duration': duration,
|
||||
'size_bytes': sizeBytes,
|
||||
'mp4': mp4,
|
||||
'created_at': createdAt,
|
||||
};
|
||||
|
||||
factory DownloadItem.fromJson(Map<String, dynamic> 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<int, double> progress; // 下载中
|
||||
final Map<int, String> errors; // 失败信息
|
||||
final List<DownloadItem> items; // 已完成(按下载时间倒序)
|
||||
|
||||
DownloadsState copyWith({
|
||||
Map<int, double>? progress,
|
||||
Map<int, String>? errors,
|
||||
List<DownloadItem>? items,
|
||||
}) =>
|
||||
DownloadsState(
|
||||
progress: progress ?? this.progress,
|
||||
errors: errors ?? this.errors,
|
||||
items: items ?? this.items,
|
||||
);
|
||||
}
|
||||
|
||||
class DownloadManager extends StateNotifier<DownloadsState> {
|
||||
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<int, CancelToken> _cancel = {};
|
||||
|
||||
bool isDownloaded(int videoId) =>
|
||||
state.items.any((e) => e.videoId == videoId);
|
||||
bool isDownloading(int videoId) => state.progress.containsKey(videoId);
|
||||
|
||||
/// 启动时扫描下载目录恢复列表
|
||||
Future<void> init() async {
|
||||
final dir = LocalMediaServer.I.downloadsDir;
|
||||
final items = <DownloadItem>[];
|
||||
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<String, dynamic>));
|
||||
} catch (_) {}
|
||||
}
|
||||
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
state = state.copyWith(items: items);
|
||||
}
|
||||
|
||||
/// 开始下载(已在下载/已下载则忽略)
|
||||
Future<void> 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<int, double>.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<int, double>.from(state.progress)
|
||||
..remove(item.videoId);
|
||||
if (e is DioException && CancelToken.isCancel(e)) {
|
||||
state = state.copyWith(progress: progress);
|
||||
} else {
|
||||
final errors = Map<int, String>.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<void> 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<int> _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<int> _downloadHls(
|
||||
String url, Directory dir, int videoId, CancelToken cancel) async {
|
||||
var playlist = (await _dio.get<String>(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<String>(variantUrl, cancelToken: cancel)).data ?? '';
|
||||
base = Uri.parse(variantUrl);
|
||||
}
|
||||
|
||||
final lines = const LineSplitter().convert(playlist);
|
||||
final segUrls = <Uri>[
|
||||
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 = <String>[];
|
||||
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 = <String, String>{};
|
||||
for (var i = 0; i < keyUris.length; i++) {
|
||||
final name = i == 0 ? 'key.bin' : 'key_$i.bin';
|
||||
final bytes = (await _dio.get<List<int>>(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<List<int>>(
|
||||
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<int, double>.from(state.progress);
|
||||
progress[videoId] = value;
|
||||
state = state.copyWith(progress: progress);
|
||||
}
|
||||
}
|
||||
|
||||
final downloadManagerProvider =
|
||||
StateNotifierProvider<DownloadManager, DownloadsState>(
|
||||
(ref) => DownloadManager());
|
||||
Reference in New Issue
Block a user