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());
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shelf/shelf.dart' as shelf;
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
/// 本地媒体服务(127.0.0.1 环回 HTTP):
|
||||
///
|
||||
/// 1. HLS 分片缓存(1G LRU):播放器请求经 /hls 改写 m3u8 内的 ts/key 绝对
|
||||
/// 地址到 /seg,/seg 命中磁盘缓存直接返回,未命中回源并落盘 —— 回看不下载。
|
||||
/// 注意:ts 链接带时效签名,缓存键取「去 query 的路径 sha1」,m3u8 每次回源。
|
||||
/// 2. 已下载视频离线播放:/local/<videoId>/<file> 直接读下载目录(支持 Range)。
|
||||
///
|
||||
/// 只有 m3u8 走代理;mp4 直连(库里仅个位数 mp4,不做缓存)。
|
||||
class LocalMediaServer {
|
||||
LocalMediaServer._();
|
||||
static final LocalMediaServer I = LocalMediaServer._();
|
||||
|
||||
static const int _cacheLimit = 1024 * 1024 * 1024; // 1G
|
||||
static const int _evictTo = 900 * 1024 * 1024; // 清理到 900M(滞后防抖)
|
||||
|
||||
final Dio _dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(minutes: 5),
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
|
||||
},
|
||||
));
|
||||
|
||||
bool _started = false;
|
||||
late int _port;
|
||||
late Directory cacheDir; // <temp>/hls_cache(系统可清理,适合缓存语义)
|
||||
late Directory downloadsDir; // <appSupport>/downloads(持久)
|
||||
|
||||
int _cacheBytes = 0;
|
||||
int _writesSinceEvictCheck = 0;
|
||||
|
||||
/// 同一分片并发回源去重(playlist 内可能重复引用 / 多槽位同时 open)
|
||||
final Map<String, Future<List<int>>> _inflight = {};
|
||||
|
||||
Future<void> init() async {
|
||||
if (_started) return;
|
||||
final tmp = await getTemporaryDirectory();
|
||||
cacheDir = Directory(p.join(tmp.path, 'hls_cache'));
|
||||
await cacheDir.create(recursive: true);
|
||||
final support = await getApplicationSupportDirectory();
|
||||
downloadsDir = Directory(p.join(support.path, 'downloads'));
|
||||
await downloadsDir.create(recursive: true);
|
||||
_cacheBytes = await _dirSize(cacheDir);
|
||||
|
||||
final router = Router()
|
||||
..get('/hls', _handlePlaylist)
|
||||
..get('/seg', _handleSegment)
|
||||
..get('/local/<vid>/<name>', _handleLocal);
|
||||
|
||||
final server = await shelf_io.serve(
|
||||
const shelf.Pipeline().addHandler(router.call),
|
||||
InternetAddress.loopbackIPv4,
|
||||
0,
|
||||
);
|
||||
_port = server.port;
|
||||
_started = true;
|
||||
}
|
||||
|
||||
/// 播放地址改写:网络 m3u8 → 本地缓存代理;其余(本地地址/mp4/空)原样返回
|
||||
String wrap(String url) {
|
||||
if (!_started || url.isEmpty) return url;
|
||||
final u = Uri.tryParse(url);
|
||||
if (u == null || (u.scheme != 'http' && u.scheme != 'https')) return url;
|
||||
if (u.host == '127.0.0.1' || u.host == 'localhost') return url;
|
||||
if (url.contains('m3u8')) {
|
||||
return 'http://127.0.0.1:$_port/hls?src=${Uri.encodeComponent(url)}';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/// 已下载视频的本地播放地址
|
||||
String localPlayUrl(int videoId, {bool mp4 = false}) =>
|
||||
'http://127.0.0.1:$_port/local/$videoId/${mp4 ? 'video.mp4' : 'index.m3u8'}';
|
||||
|
||||
// ---------- /hls:回源拉 m3u8,改写分片地址到 /seg ----------
|
||||
|
||||
Future<shelf.Response> _handlePlaylist(shelf.Request req) async {
|
||||
final src = req.url.queryParameters['src'];
|
||||
if (src == null || src.isEmpty) {
|
||||
return shelf.Response(400, body: 'missing src');
|
||||
}
|
||||
try {
|
||||
final resp = await _dio.get<String>(src);
|
||||
final text = resp.data ?? '';
|
||||
if (!text.trimLeft().startsWith('#EXTM3U')) {
|
||||
return shelf.Response(502, body: 'bad playlist');
|
||||
}
|
||||
return _bytesResponse(
|
||||
req,
|
||||
utf8.encode(_rewritePlaylist(text, Uri.parse(src))),
|
||||
'application/vnd.apple.mpegurl',
|
||||
noCache: true,
|
||||
);
|
||||
} catch (e) {
|
||||
return shelf.Response(502, body: 'upstream error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _segProxyUrl(Uri absolute) =>
|
||||
'http://127.0.0.1:$_port/seg?u=${Uri.encodeComponent(absolute.toString())}';
|
||||
|
||||
String _rewritePlaylist(String text, Uri base) {
|
||||
final out = StringBuffer();
|
||||
for (final rawLine in const LineSplitter().convert(text)) {
|
||||
final line = rawLine.trim();
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith('#')) {
|
||||
// 改写标签内的 URI="..."(EXT-X-KEY / EXT-X-MAP 等)
|
||||
out.writeln(line.replaceAllMapped(
|
||||
RegExp(r'URI="([^"]+)"'),
|
||||
(m) => 'URI="${_segProxyUrl(base.resolve(m.group(1)!))}"',
|
||||
));
|
||||
} else {
|
||||
out.writeln(_segProxyUrl(base.resolve(line)));
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
// ---------- /seg:分片缓存(键 = 去签名路径 sha1)----------
|
||||
|
||||
/// 统一字节响应:始终声明 Accept-Ranges,带 Range 头则返回 206 切片。
|
||||
/// (ffmpeg/mpv 依据 Accept-Ranges 判断流可拖动,否则按直播处理:
|
||||
/// duration=0 → 进度条不走、无法 seek)
|
||||
shelf.Response _bytesResponse(
|
||||
shelf.Request req, List<int> bytes, String contentType,
|
||||
{bool noCache = false}) {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
if (noCache) 'Cache-Control': 'no-cache' else 'Cache-Control': 'max-age=31536000',
|
||||
};
|
||||
final range = req.headers['range'];
|
||||
if (range != null && range.startsWith('bytes=')) {
|
||||
final m = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(range);
|
||||
if (m != null) {
|
||||
final start = int.parse(m.group(1)!);
|
||||
if (start < bytes.length) {
|
||||
final endStr = m.group(2) ?? '';
|
||||
final end = endStr.isEmpty
|
||||
? bytes.length - 1
|
||||
: int.parse(endStr).clamp(0, bytes.length - 1);
|
||||
return shelf.Response(206,
|
||||
body: bytes.sublist(start, end + 1),
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Range': 'bytes $start-$end/${bytes.length}',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return shelf.Response.ok(bytes, headers: headers);
|
||||
}
|
||||
|
||||
String _cacheKeyFor(Uri u) {
|
||||
final stripped = Uri(scheme: u.scheme, host: u.host, port: u.port, path: u.path);
|
||||
return sha1.convert(utf8.encode(stripped.toString())).toString();
|
||||
}
|
||||
|
||||
Future<shelf.Response> _handleSegment(shelf.Request req) async {
|
||||
final raw = req.url.queryParameters['u'];
|
||||
if (raw == null || raw.isEmpty) return shelf.Response(400, body: 'missing u');
|
||||
final u = Uri.tryParse(raw);
|
||||
if (u == null) return shelf.Response(400, body: 'bad u');
|
||||
|
||||
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
|
||||
if (await file.exists()) {
|
||||
// 触活:LRU 以 mtime 排序
|
||||
await file.setLastModified(DateTime.now());
|
||||
return _bytesResponse(req, await file.readAsBytes(), _contentType(u.path));
|
||||
}
|
||||
|
||||
try {
|
||||
final bytes = await (_inflight[raw] ??= _fetch(u));
|
||||
return _bytesResponse(req, bytes, _contentType(u.path));
|
||||
} catch (e) {
|
||||
return shelf.Response(502, body: 'fetch error: $e');
|
||||
} finally {
|
||||
unawaited(Future(() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 30));
|
||||
_inflight.remove(raw);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> _fetch(Uri u) async {
|
||||
final resp = await _dio.get<List<int>>(
|
||||
u.toString(),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
final bytes = resp.data ?? const <int>[];
|
||||
if (bytes.isEmpty) throw Exception('empty body');
|
||||
|
||||
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
|
||||
// tmp 名唯一化:不同签名的同一分片并发回源时避免写同一个临时文件
|
||||
final tmp = File(
|
||||
'${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp');
|
||||
await tmp.writeAsBytes(bytes, flush: true);
|
||||
await tmp.rename(file.path);
|
||||
_cacheBytes += bytes.length;
|
||||
_maybeEvict();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// LRU 清理:超 1G 时按 mtime 从旧到新删到 900M(每 20 次写入最多全扫一次)
|
||||
void _maybeEvict() {
|
||||
if (_cacheBytes <= _cacheLimit) return;
|
||||
if (++_writesSinceEvictCheck < 20) return;
|
||||
_writesSinceEvictCheck = 0;
|
||||
unawaited(Future(() async {
|
||||
final entries = <File>[];
|
||||
await for (final e in cacheDir.list()) {
|
||||
if (e is File) entries.add(e);
|
||||
}
|
||||
entries.sort((a, b) =>
|
||||
a.lastModifiedSync().compareTo(b.lastModifiedSync()));
|
||||
for (final f in entries) {
|
||||
if (_cacheBytes <= _evictTo) break;
|
||||
try {
|
||||
final len = await f.length();
|
||||
await f.delete();
|
||||
_cacheBytes -= len;
|
||||
} catch (_) {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- /local:已下载内容(Range 支持 mp4 拖动)----------
|
||||
|
||||
Future<shelf.Response> _handleLocal(
|
||||
shelf.Request req, String vid, String name) async {
|
||||
// 防路径穿越
|
||||
if (name.contains('/') || name.contains('..') || vid.contains('..')) {
|
||||
return shelf.Response(400, body: 'bad path');
|
||||
}
|
||||
final file = File(p.join(downloadsDir.path, vid, name));
|
||||
if (!await file.exists()) return shelf.Response.notFound('gone');
|
||||
|
||||
final total = await file.length();
|
||||
final range = req.headers['range'];
|
||||
if (range != null && range.startsWith('bytes=')) {
|
||||
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
|
||||
if (m != null) {
|
||||
final start = int.tryParse(m.group(1) ?? '') ?? 0;
|
||||
final endStr = m.group(2) ?? '';
|
||||
final end = endStr.isEmpty
|
||||
? total - 1
|
||||
: (int.tryParse(endStr) ?? total - 1).clamp(0, total - 1);
|
||||
final raf = await file.open();
|
||||
try {
|
||||
await raf.setPosition(start);
|
||||
final bytes = await raf.read(end - start + 1);
|
||||
return shelf.Response(206,
|
||||
body: bytes,
|
||||
headers: {
|
||||
'Content-Type': _contentType(name),
|
||||
'Content-Range': 'bytes $start-$end/$total',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': '${bytes.length}',
|
||||
});
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
return shelf.Response.ok(await file.readAsBytes(), headers: {
|
||||
'Content-Type': _contentType(name),
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
}
|
||||
|
||||
String _contentType(String path) {
|
||||
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
|
||||
if (path.endsWith('.ts')) return 'video/mp2t';
|
||||
if (path.endsWith('.mp4')) return 'video/mp4';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
Future<int> _dirSize(Directory dir) async {
|
||||
var total = 0;
|
||||
await for (final e in dir.list()) {
|
||||
if (e is File) {
|
||||
try {
|
||||
total += await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
@@ -199,49 +199,52 @@ class _ErrorInterceptor extends Interceptor {
|
||||
final apiError = err.error;
|
||||
final status = err.response?.statusCode;
|
||||
|
||||
// 401:静默刷新重试一次
|
||||
// 401:静默刷新重试一次;无法刷新(无 refresh / 刷新被拒 / 重试仍 401)
|
||||
// 统一按会话失效处理,否则 APP 会卡在"本地有 user 但 access 已过期"的
|
||||
// 僵尸登录态:我的页正常显示资料卡,点会员/钱包等全部 401 加载失败
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
String? newToken;
|
||||
try {
|
||||
newToken = await _refreshToken();
|
||||
} on ApiException catch (refreshErr) {
|
||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: refreshErr,
|
||||
));
|
||||
}
|
||||
if (newToken != null) {
|
||||
err.requestOptions.extra['retried'] != true) {
|
||||
if (TokenStore.refreshToken != null) {
|
||||
String? newToken;
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['retried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (retryErr) {
|
||||
final retryApi = retryErr.error;
|
||||
final retryStatus = retryErr.response?.statusCode;
|
||||
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
|
||||
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
|
||||
final stillAuthFailed = retryStatus == 401 ||
|
||||
(retryApi is ApiException && retryApi.isUnauthorized);
|
||||
if (!stillAuthFailed) {
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: retryApi is ApiException
|
||||
? retryApi
|
||||
: ApiException(-1, '网络异常,请稍后重试'),
|
||||
));
|
||||
}
|
||||
} catch (_) {
|
||||
// 非 Dio 失败:视为网络异常,不清登录态
|
||||
newToken = await _refreshToken();
|
||||
} on ApiException catch (refreshErr) {
|
||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(-1, '网络异常,请稍后重试'),
|
||||
error: refreshErr,
|
||||
));
|
||||
}
|
||||
if (newToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['retried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (retryErr) {
|
||||
final retryApi = retryErr.error;
|
||||
final retryStatus = retryErr.response?.statusCode;
|
||||
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
|
||||
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
|
||||
final stillAuthFailed = retryStatus == 401 ||
|
||||
(retryApi is ApiException && retryApi.isUnauthorized);
|
||||
if (!stillAuthFailed) {
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: retryApi is ApiException
|
||||
? retryApi
|
||||
: ApiException(-1, '网络异常,请稍后重试'),
|
||||
));
|
||||
}
|
||||
} catch (_) {
|
||||
// 非 Dio 失败:视为网络异常,不清登录态
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(-1, '网络异常,请稍后重试'),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
await TokenStore.clear();
|
||||
ApiClient.onSessionExpired?.call();
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../media/local_media_server.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
class PlayerSlot {
|
||||
PlayerSlot(this.player);
|
||||
@@ -44,6 +46,20 @@ class FeedPlayerPool {
|
||||
int _currentIndex = 0;
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// 代际计数器:每次 bindWindow 自增,await 恢复后校验,过期调用直接退出。
|
||||
/// 快速连划时多个 bindWindow 并发 await open,无互斥会导致旧调用恢复后
|
||||
/// 误 play 装有旧媒体的槽位(幽灵声音来源)。
|
||||
int _generation = 0;
|
||||
|
||||
/// 是否挂起(切出首页 Tab / App 退后台):挂起期间 bindWindow 的 open
|
||||
/// 完成后不允许自动 play —— 否则「先暂停、open 后完成又 play」的竞态
|
||||
/// 会让视频在后台继续出声(切到"我的"听到同一视频声音的根因)
|
||||
bool _suspended = false;
|
||||
|
||||
/// 最近一次 bindWindow(resume 时需等在途 open 完成再 play,避免 play 被
|
||||
/// open(play:false) 内部的 pause 覆盖导致恢复后不播)
|
||||
Future<void>? _pendingBind;
|
||||
|
||||
/// 初始化(创建 3 槽位并订阅各自进度流)
|
||||
void init() {
|
||||
for (var i = 0; i < slotCount; i++) {
|
||||
@@ -65,26 +81,40 @@ class FeedPlayerPool {
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||
///
|
||||
/// 窗口策略 [index, index+1]:只保留当前页(播放)和下一页(预缓冲)。
|
||||
/// 上一页槽位 stop 清空:一是满足"划走即停止缓存下载"(pause 不停止缓冲,
|
||||
/// stop 才断开),二是 stop 后槽内无媒体,即使误 play 也不会出声(防幽灵)。
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) {
|
||||
final fut = _bindWindow(index, items);
|
||||
_pendingBind = fut;
|
||||
return fut;
|
||||
}
|
||||
|
||||
Future<void> _bindWindow(
|
||||
int index, List<({int videoId, String url})> items) async {
|
||||
final gen = ++_generation;
|
||||
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
|
||||
_currentIndex = index;
|
||||
|
||||
// 窗口内应保留的槽位集合 [index-1, index, index+1]
|
||||
// 窗口内应保留的槽位集合 [index, index+1]
|
||||
final activeSet = <int>{};
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||
}
|
||||
|
||||
// 2. 立即静音(同步执行,不等后续网络 open):
|
||||
// - 出窗槽位:stop(断音 + 清缓存),并复位
|
||||
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
|
||||
// - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
|
||||
// - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
|
||||
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (slot.videoId < 0) continue;
|
||||
if (activeSet.contains(s)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
if (s != slotOf(index)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
unawaited(slot.player.stop());
|
||||
slot.videoId = -1;
|
||||
@@ -93,7 +123,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
|
||||
// 3. 窗口内槽位按需加载(open 完才 play)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
@@ -102,14 +132,44 @@ class FeedPlayerPool {
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
await slot.player.open(Media(item.url), play: false);
|
||||
// 经本地代理:HLS 分片 1G LRU 缓存,回看命中缓存不再下载
|
||||
await slot.player.open(Media(LocalMediaServer.I.wrap(item.url)), play: false);
|
||||
// await 期间用户可能又翻页:本次绑定已过期,退出(最新绑定会接管)
|
||||
if (gen != _generation) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 当前槽:播放
|
||||
// 4. 当前槽:播放(播前再校验代际与挂起态,防过期调用/后台挂起时唤醒媒体)
|
||||
if (gen != _generation || _suspended) return;
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
/// 挂起(切出首页底部 Tab / App 退后台 / 切走当前策略):
|
||||
/// 立即暂停当前槽,并阻止在途 bindWindow 的 open 完成后自动播放
|
||||
void suspend() {
|
||||
_suspended = true;
|
||||
try {
|
||||
currentSlot.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 恢复(切回首页 / App 回前台 / 策略切回):解除挂起;若 bind 仍在 open,
|
||||
/// 等其完成后再 play,避免 play 被 open(play:false) 的内部 pause 覆盖
|
||||
Future<void> resume() async {
|
||||
_suspended = false;
|
||||
final pending = _pendingBind;
|
||||
if (pending != null) {
|
||||
try {
|
||||
await pending;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!_suspended) {
|
||||
try {
|
||||
currentSlot.player.play();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||
void _hookSlot(PlayerSlot slot) {
|
||||
var lastPosition = Duration.zero;
|
||||
@@ -135,7 +195,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
}
|
||||
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
||||
if (slot == currentSlot) {
|
||||
if (slot == currentSlot && !_suspended) {
|
||||
slot.player.seek(Duration.zero);
|
||||
slot.player.play();
|
||||
}
|
||||
@@ -155,6 +215,22 @@ class FeedPlayerPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部停止并清空(进入全屏播放器前调用):停音 + 断缓存 + 清媒体。
|
||||
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
||||
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
||||
void stopAll() {
|
||||
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
||||
_generation++;
|
||||
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
||||
_suspended = false;
|
||||
for (final slot in _slots) {
|
||||
if (slot.videoId < 0) continue;
|
||||
unawaited(slot.player.stop());
|
||||
slot.videoId = -1;
|
||||
slot.completeNotified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subs) {
|
||||
|
||||
Reference in New Issue
Block a user