refa: download video (#1737)

* opt: save pb danmaku

* refa: download video

* opt: replaceAll

* fix: wait delete

* opt: remove completer

* fix: index.json

* tweaks

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>

---------

Co-authored-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
My-Responsitories
2025-11-12 19:12:17 +08:00
committed by GitHub
parent 37b1228552
commit 407b31c5c1
16 changed files with 345 additions and 410 deletions

View File

@@ -119,66 +119,69 @@ class BiliDownloadEntryInfo {
required DownloadService downloadService, required DownloadService downloadService,
required bool isPage, required bool isPage,
}) { }) {
return Obx(() { return RepaintBoundary(
final curDownload = downloadService.curDownload.value; // TODO: refresh `downloadedBytes` only cause unnecessary repaint
final isCurr = child: Obx(() {
curDownload != null && final curDownload = downloadService.curDownload.value;
(isPage ? curDownload.pageId == pageId : curDownload.cid == cid); final isCurr =
late final status = curDownload?.status; curDownload != null &&
late final isDownloading = status == DownloadStatus.downloading; (isPage ? curDownload.pageId == pageId : curDownload.cid == cid);
late final color = isCurr && status != DownloadStatus.pause late final status = curDownload?.status;
? theme.colorScheme.primary late final isDownloading = status == DownloadStatus.downloading;
: theme.colorScheme.outline; late final color = isCurr && status != DownloadStatus.pause
return Column( ? theme.colorScheme.primary
spacing: 6, : theme.colorScheme.outline;
mainAxisSize: MainAxisSize.min, return Column(
crossAxisAlignment: CrossAxisAlignment.start, spacing: 6,
children: [ mainAxisSize: MainAxisSize.min,
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Row(
Text( mainAxisAlignment: MainAxisAlignment.spaceBetween,
isCurr ? status!.message : '暂停中', children: [
style: TextStyle( Text(
fontSize: 12, isCurr ? status!.message : '暂停中',
height: 1, style: TextStyle(
color: color, fontSize: 12,
height: 1,
color: color,
),
), ),
), Text(
Text( isCurr
isCurr ? isDownloading || status == DownloadStatus.pause
? isDownloading || status == DownloadStatus.pause ? ' ${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}'
? ' ${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}' : ''
: '' : totalBytes == 0
: totalBytes == 0 ? ''
? '' : ' ${CacheManager.formatSize(downloadedBytes)}/${CacheManager.formatSize(totalBytes)}',
: ' ${CacheManager.formatSize(downloadedBytes)}/${CacheManager.formatSize(totalBytes)}', style: TextStyle(
style: TextStyle( fontSize: 12,
fontSize: 12, height: 1,
height: 1, color: color,
color: color, ),
), ),
), ],
], ),
), LinearProgressIndicator(
LinearProgressIndicator( // ignore: deprecated_member_use
// ignore: deprecated_member_use year2023: true,
year2023: true, minHeight: 2.5,
minHeight: 2.5, borderRadius: StyleString.mdRadius,
borderRadius: StyleString.mdRadius, color: color,
color: color, backgroundColor: theme.highlightColor,
backgroundColor: theme.highlightColor, value: isCurr
value: isCurr ? curDownload.totalBytes == 0
? curDownload.totalBytes == 0 ? 0
? 0 : curDownload.downloadedBytes / curDownload.totalBytes
: curDownload.downloadedBytes / curDownload.totalBytes : totalBytes == 0
: totalBytes == 0 ? 0
? 0 : downloadedBytes / totalBytes,
: downloadedBytes / totalBytes, ),
), ],
], );
); }),
}); );
} }
BiliDownloadEntryInfo({ BiliDownloadEntryInfo({

View File

@@ -1,14 +1,12 @@
import 'dart:convert';
import 'dart:io' show File; import 'dart:io' show File;
import 'package:PiliPlus/grpc/bilibili/community/service/dm/v1.pb.dart'; import 'package:PiliPlus/grpc/bilibili/community/service/dm/v1.pb.dart';
import 'package:PiliPlus/grpc/dm.dart'; import 'package:PiliPlus/grpc/dm.dart';
import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart';
import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts.dart';
import 'package:fixnum/fixnum.dart' show Int64; import 'package:PiliPlus/utils/path_utils.dart';
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:xml/xml.dart';
class PlDanmakuController { class PlDanmakuController {
PlDanmakuController( PlDanmakuController(
@@ -36,7 +34,7 @@ class PlDanmakuController {
requestedSeg.clear(); requestedSeg.clear();
} }
int calcSegment(int progress) { static int calcSegment(int progress) {
return progress ~/ segmentLength; return progress ~/ segmentLength;
} }
@@ -102,7 +100,7 @@ class PlDanmakuController {
List<DanmakuElem>? getCurrentDanmaku(int progress) { List<DanmakuElem>? getCurrentDanmaku(int progress) {
if (isFileSource) { if (isFileSource) {
initXmlDmIfNeeded(); initFileDmIfNeeded();
} else { } else {
final int segmentIndex = calcSegment(progress); final int segmentIndex = calcSegment(progress);
if (!requestedSeg.contains(segmentIndex)) { if (!requestedSeg.contains(segmentIndex)) {
@@ -115,68 +113,24 @@ class PlDanmakuController {
bool closed = false; bool closed = false;
late bool _xmlDmLoaded = false; bool _fileDmLoaded = false;
void initXmlDmIfNeeded() { void initFileDmIfNeeded() {
if (_xmlDmLoaded) return; if (_fileDmLoaded) return;
_xmlDmLoaded = true; _fileDmLoaded = true;
_initXmlDm(); _initFileDm();
} }
Future<void> _initXmlDm() async { Future<void> _initFileDm() async {
try { try {
final file = File(path.join(plPlayerController.dirPath!, 'danmaku.xml')); final file = File(
final stream = file.openRead().transform(utf8.decoder); path.join(plPlayerController.dirPath!, PathUtils.danmakuName),
final buffer = StringBuffer(); );
await for (final chunk in stream) { if (!file.existsSync()) return;
if (closed) { final bytes = await file.readAsBytes();
return; if (bytes.isEmpty) return;
} final elem = DmSegMobileReply.fromBuffer(bytes).elems;
buffer.write(chunk); handleDanmaku(elem);
}
if (closed) {
return;
}
final xmlString = buffer.toString();
final document = XmlDocument.parse(xmlString);
final danmakus = document.findAllElements('d').toList();
final elems = <DanmakuElem>[];
for (final dm in danmakus) {
if (closed) {
return;
}
try {
final pAttr = dm.getAttribute('p');
if (pAttr != null) {
final parts = pAttr.split(',');
final progress = double.parse(parts[0]); // sec
final mode = int.parse(parts[1]);
final fontsize = int.parse(parts[2]);
final color = int.parse(parts[3]);
// final ctime = int.parse(parts[4]);
// final pool = int.parse(parts[5]);
final midHash = parts[6];
final id = int.parse(parts[7]);
final weight = int.parse(parts[8]);
final content = dm.innerText;
elems.add(
DanmakuElem(
progress: (progress * 1000).toInt(),
mode: mode,
fontsize: fontsize,
color: color,
midHash: midHash,
id: Int64(id),
weight: weight,
content: content,
),
);
}
} catch (_) {
if (kDebugMode) rethrow;
}
}
handleDanmaku(elems);
} catch (_) { } catch (_) {
if (kDebugMode) rethrow; if (kDebugMode) rethrow;
} }

View File

@@ -48,10 +48,10 @@ class _PlDanmakuState extends State<PlDanmaku> {
); );
if (playerController.enableShowDanmaku.value) { if (playerController.enableShowDanmaku.value) {
if (widget.isFileSource) { if (widget.isFileSource) {
_plDanmakuController.initXmlDmIfNeeded(); _plDanmakuController.initFileDmIfNeeded();
} else { } else {
_plDanmakuController.queryDanmaku( _plDanmakuController.queryDanmaku(
_plDanmakuController.calcSegment( PlDanmakuController.calcSegment(
playerController.position.value.inMilliseconds, playerController.position.value.inMilliseconds,
), ),
); );

View File

@@ -6,7 +6,6 @@ import 'package:get/get.dart';
class DownloadPageController extends GetxController { class DownloadPageController extends GetxController {
final _downloadService = Get.find<DownloadService>(); final _downloadService = Get.find<DownloadService>();
late final StreamSubscription _sub;
final pages = RxList<DownloadPageInfo>(); final pages = RxList<DownloadPageInfo>();
final flag = RxInt(0); final flag = RxInt(0);
@@ -14,14 +13,12 @@ class DownloadPageController extends GetxController {
void onInit() { void onInit() {
super.onInit(); super.onInit();
_loadList(); _loadList();
_sub = _downloadService.downloaFlag.listen((_) { _downloadService.flagNotifier.add(_loadList);
_loadList();
});
} }
@override @override
void onClose() { void onClose() {
_sub.cancel(); _downloadService.flagNotifier.remove(_loadList);
super.onClose(); super.onClose();
} }

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart';
@@ -10,12 +12,15 @@ import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart';
import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/services/download/download_service.dart';
import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/cache_manager.dart';
import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/path_utils.dart';
import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/utils.dart'; import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' as path;
class DetailItem extends StatelessWidget { class DetailItem extends StatelessWidget {
const DetailItem({ const DetailItem({
@@ -141,11 +146,35 @@ class DetailItem extends StatelessWidget {
AspectRatio( AspectRatio(
aspectRatio: StyleString.aspectRatio, aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) => NetworkImgLayer( builder: (context, constraints) {
src: entry.cover, final cover = File(
width: constraints.maxWidth, path.join(entry.entryDirPath, PathUtils.coverName),
height: constraints.maxHeight, );
), return cover.existsSync()
? ClipRRect(
borderRadius: StyleString.mdRadius,
child: Image.file(
cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
fit: BoxFit.cover,
cacheHeight: constraints.maxWidth.cacheSize(
context,
),
colorBlendMode: NetworkImgLayer.reduce
? BlendMode.modulate
: null,
color: NetworkImgLayer.reduce
? NetworkImgLayer.reduceLuxColor
: null,
),
)
: NetworkImgLayer(
src: entry.cover,
width: constraints.maxWidth,
height: constraints.maxHeight,
);
},
), ),
), ),
if (entry.videoQuality case final videoQuality?) if (entry.videoQuality case final videoQuality?)

View File

@@ -89,7 +89,7 @@ List<SettingsModel> get extraSettings => [
if (downloadPath == defPath) return; if (downloadPath == defPath) return;
downloadPath = defPath; downloadPath = defPath;
setState(); setState();
Get.find<DownloadService>().readDownloadList(); Get.find<DownloadService>().initDownloadList();
GStorage.setting.delete(SettingBoxKey.downloadPath); GStorage.setting.delete(SettingBoxKey.downloadPath);
}, },
dense: true, dense: true,
@@ -102,7 +102,7 @@ List<SettingsModel> get extraSettings => [
if (path == null || path == downloadPath) return; if (path == null || path == downloadPath) return;
downloadPath = path; downloadPath = path;
setState(); setState();
Get.find<DownloadService>().readDownloadList(); Get.find<DownloadService>().initDownloadList();
GStorage.setting.put(SettingBoxKey.downloadPath, path); GStorage.setting.put(SettingBoxKey.downloadPath, path);
}, },
dense: true, dense: true,

View File

@@ -130,9 +130,10 @@ class _DownloadPanelState extends State<DownloadPanel> {
style: const TextStyle(height: 1), style: const TextStyle(height: 1),
strutStyle: const StrutStyle(height: 1, leading: 0), strutStyle: const StrutStyle(height: 1, leading: 0),
), ),
const Icon( Icon(
size: 18, size: 18,
Icons.keyboard_arrow_down, Icons.keyboard_arrow_down,
color: theme.colorScheme.onSurfaceVariant,
), ),
], ],
), ),

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
@@ -6,8 +8,11 @@ import 'package:PiliPlus/models/common/video/video_quality.dart';
import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart';
import 'package:PiliPlus/pages/video/introduction/local/controller.dart'; import 'package:PiliPlus/pages/video/introduction/local/controller.dart';
import 'package:PiliPlus/utils/duration_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/path_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' as path;
class LocalIntroPanel extends StatefulWidget { class LocalIntroPanel extends StatefulWidget {
const LocalIntroPanel({super.key, required this.heroTag}); const LocalIntroPanel({super.key, required this.heroTag});
@@ -49,6 +54,7 @@ class _LocalIntroPanelState extends State<LocalIntroPanel>
BiliDownloadEntryInfo entry, BiliDownloadEntryInfo entry,
) { ) {
final outline = theme.colorScheme.outline; final outline = theme.colorScheme.outline;
final cover = File(path.join(entry.entryDirPath, PathUtils.coverName));
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SizedBox( child: SizedBox(
@@ -73,11 +79,28 @@ class _LocalIntroPanelState extends State<LocalIntroPanel>
Stack( Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
NetworkImgLayer( cover.existsSync()
src: entry.cover, ? ClipRRect(
width: 140.8, borderRadius: StyleString.mdRadius,
height: 88, child: Image.file(
), cover,
width: 140.8,
height: 88,
fit: BoxFit.cover,
cacheHeight: 140.8.cacheSize(context),
colorBlendMode: NetworkImgLayer.reduce
? BlendMode.modulate
: null,
color: NetworkImgLayer.reduce
? NetworkImgLayer.reduceLuxColor
: null,
),
)
: NetworkImgLayer(
src: entry.cover,
width: 140.8,
height: 88,
),
PBadge( PBadge(
text: DurationUtils.formatDuration( text: DurationUtils.formatDuration(
entry.totalTimeMilli ~/ 1000, entry.totalTimeMilli ~/ 1000,

View File

@@ -1308,7 +1308,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
), ),
if (!videoDetailController.isFileSource) if (!videoDetailController.isFileSource)
PopupMenuItem( PopupMenuItem(
onTap: () => videoDetailController.onDownload(context), onTap: () => videoDetailController.onDownload(this.context),
child: const Text('缓存视频'), child: const Text('缓存视频'),
), ),
if (videoDetailController.cover.value.isNotEmpty) if (videoDetailController.cover.value.isNotEmpty)

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Completer, StreamSubscription; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/init.dart';
@@ -9,186 +9,97 @@ import 'package:dio/dio.dart';
class DownloadManager { class DownloadManager {
final String url; final String url;
final String path; final String path;
final Function({required int progress, required int total}) onTaskRunning; final void Function(int, int)? onReceiveProgress;
final Function() onTaskComplete; final void Function([Object? error]) onDone;
final Function({
required int progress,
required int total,
required Object error,
})
onTaskError;
bool _closed = false; DownloadStatus _status = DownloadStatus.downloading;
DownloadStatus _status = DownloadStatus.wait;
DownloadStatus get status => _status; DownloadStatus get status => _status;
CancelToken? _cancelToken; final _cancelToken = CancelToken();
Completer? _completer; late Future<void> task;
DownloadManager({ DownloadManager({
required this.url, required this.url,
required this.path, required this.path,
required this.onTaskRunning, required this.onReceiveProgress,
required this.onTaskComplete, required this.onDone,
required this.onTaskError, }) {
}); task = _start();
void _complete() {
if (_completer?.isCompleted == false) {
_completer?.complete();
}
} }
Future<void> start() async { Future<void> _start() async {
_completer = Completer(); int received;
_cancelToken = CancelToken();
_status = DownloadStatus.downloading;
final file = File(path); final file = File(path);
// If the file already exists, the method fails. if (file.existsSync()) {
if (!file.existsSync()) { received = await file.length();
} else {
file.createSync(recursive: true); file.createSync(recursive: true);
received = 0;
} }
final int downloadedSize = await file.length(); final sink = file.openWrite(
mode: received == 0 ? FileMode.writeOnly : FileMode.writeOnlyAppend,
// Shouldn't call file.writeAsBytesSync(list, flush: flush),
// because it can write all bytes by once. Consider that the file is
// a very big size (up to 1 Gigabytes), it will be expensive in memory.
RandomAccessFile raf = file.openSync(
mode: downloadedSize == 0 ? FileMode.write : FileMode.append,
); );
Future<void>? asyncWrite; Future<void> onError(Object e, {bool delete = false}) async {
Future<void> closeAndDelete({bool delete = false}) async { try {
if (!_closed) { await sink.close();
_closed = true; } catch (_) {}
await asyncWrite; if (_status == DownloadStatus.downloading) {
await raf.close().catchError((_) => raf); _status = DownloadStatus.failDownload;
if (delete && file.existsSync()) { if (delete && file.existsSync()) {
await file.delete().catchError((_) => file); await file.tryDel();
} }
} }
onDone(e);
} }
final Response<ResponseBody> response; Response<ResponseBody> response;
try { try {
response = await Request.dio.get<ResponseBody>( response = await Request.dio.get<ResponseBody>(
url.http2https, url.http2https,
options: Options( options: Options(
headers: {'range': 'bytes=$downloadedSize-'}, headers: {'range': 'bytes=$received-'},
responseType: ResponseType.stream, responseType: ResponseType.stream,
validateStatus: (status) { validateStatus: (status) =>
return status == 416 || status != null &&
(status != null && status >= 200 && status < 300); (status == 416 || (status >= 200 && status < 300)),
},
), ),
cancelToken: _cancelToken, cancelToken: _cancelToken,
); );
} on DioException catch (e) { } on DioException catch (e) {
final isFailed = e.response?.statusCode != 416; await onError(e, delete: true);
if (isFailed) {
_status = DownloadStatus.failDownload;
onTaskError(progress: 0, total: 0, error: e);
} else {
_status = DownloadStatus.completed;
onTaskComplete();
}
closeAndDelete(delete: isFailed).whenComplete(_complete);
return; return;
} }
final data = response.data!;
final contentLength = data.contentLength + received;
int received = downloadedSize; if (received == 0) {
onReceiveProgress?.call(0, contentLength);
// Stream<Uint8List>
final stream = response.data!.stream;
final total =
int.parse(response.headers.value(Headers.contentLengthHeader) ?? '0') +
downloadedSize;
if (downloadedSize == 0) {
onTaskRunning(progress: 0, total: total);
} }
late StreamSubscription subscription; try {
subscription = stream.listen( await for (final chunk in data.stream) {
(data) { sink.add(chunk);
subscription.pause(); received += chunk.length;
// Write file asynchronously onReceiveProgress?.call(received, contentLength);
asyncWrite = raf }
.writeFrom(data) await sink.close();
.then((result) async { _status = DownloadStatus.completed;
// Notify progress onDone();
received += data.length; } catch (e) {
onTaskRunning(progress: received, total: total); await onError(e);
return;
raf = result;
if (_cancelToken != null && !_cancelToken!.isCancelled) {
subscription.resume();
}
})
.catchError((Object e) async {
try {
await subscription.cancel().catchError((_) {});
_closed = true;
await raf.close().catchError((_) => raf);
if (file.existsSync()) {
await file.delete().catchError((_) => file);
}
} catch (e) {
_status = DownloadStatus.failDownload;
onTaskError(progress: received, total: total, error: e);
} finally {
_complete();
}
});
},
onDone: () async {
try {
await asyncWrite;
_closed = true;
await raf.close().catchError((_) => raf);
_status = DownloadStatus.completed;
onTaskComplete();
} catch (e) {
_status = DownloadStatus.failDownload;
onTaskError(progress: received, total: total, error: e);
} finally {
_complete();
}
},
onError: (e) async {
try {
await closeAndDelete(delete: true);
} catch (e) {
_cancel();
_status = DownloadStatus.failDownload;
onTaskError(progress: received, total: total, error: e);
} finally {
_complete();
}
},
cancelOnError: true,
);
_cancelToken?.whenCancel.then((_) async {
await subscription.cancel();
await closeAndDelete();
_complete();
});
}
Future<void>? _cancel() {
if (_cancelToken != null) {
_cancelToken?.cancel();
_cancelToken = null;
} }
return _completer?.future;
} }
Future<void>? cancel({required bool isDelete}) { Future<void> cancel({required bool isDelete}) {
if (!isDelete && _status == DownloadStatus.downloading) { if (!isDelete && _status == DownloadStatus.downloading) {
_status = DownloadStatus.pause; _status = DownloadStatus.pause;
} }
return _cancel(); if (!_cancelToken.isCancelled) {
_cancelToken.cancel();
}
return task;
} }
} }

View File

@@ -1,7 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert' show jsonDecode, utf8; import 'dart:convert' show jsonDecode, jsonEncode;
import 'dart:io' show Directory, File, FileSystemEntity; import 'dart:io' show Directory, File;
import 'package:PiliPlus/grpc/dm.dart';
import 'package:PiliPlus/http/download.dart'; import 'package:PiliPlus/http/download.dart';
import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart';
@@ -12,14 +13,13 @@ import 'package:PiliPlus/models_new/pgc/pgc_info_model/result.dart';
import 'package:PiliPlus/models_new/video/video_detail/data.dart'; import 'package:PiliPlus/models_new/video/video_detail/data.dart';
import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc; import 'package:PiliPlus/models_new/video/video_detail/episode.dart' as ugc;
import 'package:PiliPlus/models_new/video/video_detail/page.dart'; import 'package:PiliPlus/models_new/video/video_detail/page.dart';
import 'package:PiliPlus/pages/danmaku/controller.dart';
import 'package:PiliPlus/services/download/download_manager.dart'; import 'package:PiliPlus/services/download/download_manager.dart';
import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/id_utils.dart'; import 'package:PiliPlus/utils/id_utils.dart';
import 'package:PiliPlus/utils/path_utils.dart'; import 'package:PiliPlus/utils/path_utils.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:archive/archive.dart' show Inflate;
import 'package:dio/dio.dart' show Options, ResponseType;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
@@ -30,12 +30,10 @@ import 'package:synchronized/synchronized.dart';
class DownloadService extends GetxService { class DownloadService extends GetxService {
static const _entryFile = 'entry.json'; static const _entryFile = 'entry.json';
static const _indexFile = 'index.json'; static const _indexFile = 'index.json';
static const _danmakuFile = 'danmaku.xml';
static const _coverFile = 'cover.jpg';
final _lock = Lock(); final _lock = Lock();
final downloaFlag = RxnInt(); final flagNotifier = <void Function()>{};
final waitDownloadQueue = <BiliDownloadEntryInfo>[]; final waitDownloadQueue = <BiliDownloadEntryInfo>[];
final downloadList = RxList<BiliDownloadEntryInfo>(); final downloadList = RxList<BiliDownloadEntryInfo>();
@@ -51,41 +49,40 @@ class DownloadService extends GetxService {
DownloadManager? _downloadManager; DownloadManager? _downloadManager;
DownloadManager? _audioDownloadManager; DownloadManager? _audioDownloadManager;
Completer? _completer; late Future<void> waitForInitialization;
Future<void>? get waitForInitialization => _completer?.future;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
readDownloadList(); initDownloadList();
} }
Future<void> readDownloadList() async { void initDownloadList() {
_completer = Completer(); waitForInitialization = _readDownloadList();
}
Future<void> _readDownloadList() async {
final downloadDir = Directory(await _getDownloadPath()); final downloadDir = Directory(await _getDownloadPath());
final list = <BiliDownloadEntryInfo>[]; final list = <BiliDownloadEntryInfo>[];
for (final dir in downloadDir.listSync()) { await for (final dir in downloadDir.list()) {
if (dir is Directory) { if (dir is Directory) {
list.addAll(await _readDownloadDirectory(dir)); list.addAll(await _readDownloadDirectory(dir));
} }
} }
downloadList.value = list downloadList.value = list
..sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp)); ..sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp));
if (!_completer!.isCompleted) {
_completer!.complete();
}
} }
Future<List<BiliDownloadEntryInfo>> _readDownloadDirectory( Future<List<BiliDownloadEntryInfo>> _readDownloadDirectory(
FileSystemEntity pageDir, Directory pageDir,
) async { ) async {
final result = <BiliDownloadEntryInfo>[]; final result = <BiliDownloadEntryInfo>[];
if (!pageDir.existsSync() || pageDir is! Directory) { if (!pageDir.existsSync()) {
return result; return result;
} }
for (final entryDir in pageDir.listSync()) { await for (final entryDir in pageDir.list()) {
if (entryDir is Directory) { if (entryDir is Directory) {
final entryFile = File(path.join(entryDir.path, _entryFile)); final entryFile = File(path.join(entryDir.path, _entryFile));
if (entryFile.existsSync()) { if (entryFile.existsSync()) {
@@ -96,7 +93,7 @@ class DownloadService extends GetxService {
..entryDirPath = entryDir.path; ..entryDirPath = entryDir.path;
result.add(entry); result.add(entry);
if (!entry.isCompleted) { if (!entry.isCompleted) {
waitDownloadQueue.add(entry); waitDownloadQueue.add(entry..status = DownloadStatus.wait);
} }
} catch (_) { } catch (_) {
if (kDebugMode) rethrow; if (kDebugMode) rethrow;
@@ -141,7 +138,7 @@ class DownloadService extends GetxService {
downloadedBytes: 0, downloadedBytes: 0,
title: videoDetail?.title ?? videoArc!.title!, title: videoDetail?.title ?? videoArc!.title!,
typeTag: videoQuality.code.toString(), typeTag: videoQuality.code.toString(),
cover: videoDetail?.pic ?? videoArc!.cover!, cover: (videoDetail?.pic ?? videoArc!.cover!).http2https,
preferedVideoQuality: videoQuality.code, preferedVideoQuality: videoQuality.code,
qualityPithyDescription: videoQuality.desc, qualityPithyDescription: videoQuality.desc,
guessedTotalBytes: 0, guessedTotalBytes: 0,
@@ -234,14 +231,13 @@ class DownloadService extends GetxService {
Future<void> _createDownload(BiliDownloadEntryInfo entry) async { Future<void> _createDownload(BiliDownloadEntryInfo entry) async {
final entryDir = await _getDownloadEntryDir(entry); final entryDir = await _getDownloadEntryDir(entry);
final entryJsonFile = File(path.join(entryDir.path, _entryFile)); final entryJsonFile = File(path.join(entryDir.path, _entryFile));
final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); await entryJsonFile.writeAsString(jsonEncode(entry.toJson()));
await entryJsonFile.writeAsBytes(utf8.encode(entryJsonStr));
entry entry
..pageDirPath = entryDir.parent.path ..pageDirPath = entryDir.parent.path
..entryDirPath = entryDir.path ..entryDirPath = entryDir.path
..status = DownloadStatus.wait; ..status = DownloadStatus.wait;
downloadList.insert(0, entry); downloadList.insert(0, entry);
downloaFlag.refresh(); flagNotifier.refresh();
final currStatus = curDownload.value?.status?.index; final currStatus = curDownload.value?.status?.index;
if (currStatus == null || currStatus > 3) { if (currStatus == null || currStatus > 3) {
startDownload(entry); startDownload(entry);
@@ -269,7 +265,7 @@ class DownloadService extends GetxService {
return pageDir; return pageDir;
} }
Future<String> _getDownloadPath() async { static Future<String> _getDownloadPath() async {
final dir = Directory(downloadPath); final dir = Directory(downloadPath);
if (!dir.existsSync()) { if (!dir.existsSync()) {
await dir.create(recursive: true); await dir.create(recursive: true);
@@ -301,26 +297,38 @@ class DownloadService extends GetxService {
if (cid == null) { if (cid == null) {
return false; return false;
} }
final danmakuXMLFile = File(path.join(entry.entryDirPath, _danmakuFile)); final danmakuFile = File(
if (isUpdate || !danmakuXMLFile.existsSync()) { path.join(entry.entryDirPath, PathUtils.danmakuName),
);
if (isUpdate || !danmakuFile.existsSync()) {
try { try {
if (!isUpdate) { if (!isUpdate) {
_updateCurStatus(DownloadStatus.getDanmaku); _updateCurStatus(DownloadStatus.getDanmaku);
} }
final res = await Request().get( final seg = (entry.totalTimeMilli / PlDanmakuController.segmentLength)
'https://comment.bilibili.com/$cid.xml', .ceil();
options: Options(responseType: ResponseType.bytes),
); final res = await Future.wait([
final xmlBytes = Inflate((res.data as Uint8List)).getBytes(); for (var i = 1; i <= seg; i++)
await danmakuXMLFile.writeAsBytes(xmlBytes); DmGrpc.dmSegMobile(cid: cid, segmentIndex: i),
]);
final danmaku = res.removeAt(0).data;
for (var i in res) {
if (!i.isSuccess) {
throw i.toString();
}
danmaku.elems.addAll(i.data.elems);
}
res.clear();
await danmakuFile.writeAsBytes(danmaku.writeToBuffer());
return true; return true;
} catch (e) { } catch (e) {
if (!isUpdate) { if (!isUpdate) {
_updateCurStatus(DownloadStatus.failDanmaku); _updateCurStatus(DownloadStatus.failDanmaku);
} }
if (kDebugMode) { if (kDebugMode) SmartDialog.showToast(e.toString());
SmartDialog.showToast(e.toString());
}
return false; return false;
} }
} }
@@ -331,13 +339,22 @@ class DownloadService extends GetxService {
required BiliDownloadEntryInfo entry, required BiliDownloadEntryInfo entry,
}) async { }) async {
try { try {
await Request.dio.download( final filePath = path.join(entry.entryDirPath, PathUtils.coverName);
entry.cover.http2https, if (File(filePath).existsSync()) {
path.join(entry.entryDirPath, _coverFile), return true;
); }
final file = (await DefaultCacheManager().getFileFromCache(
entry.cover,
))?.file;
if (file != null) {
await file.copy(filePath);
} else {
await Request.dio.download(entry.cover, filePath);
}
return true; return true;
} catch (_) {} } catch (_) {
return false; return false;
}
} }
Future<void> _startDownload(BiliDownloadEntryInfo entry) async { Future<void> _startDownload(BiliDownloadEntryInfo entry) async {
@@ -357,18 +374,16 @@ class DownloadService extends GetxService {
await videoDir.create(recursive: true); await videoDir.create(recursive: true);
} }
final res = await Future.wait([ final coverTask = _downloadCover(entry: entry);
downloadDanmaku(entry: entry),
_downloadCover(entry: entry),
]);
if (!res.first) { if (!await downloadDanmaku(entry: entry)) {
return; return;
} }
final mediaJsonFile = File(path.join(videoDir.path, _indexFile)); final mediaJsonFile = File(path.join(videoDir.path, _indexFile));
final mediaJsonStr = Utils.jsonEncoder.convert(mediaFileInfo.toJson()); await Future.wait([
await mediaJsonFile.writeAsString(mediaJsonStr); mediaJsonFile.writeAsString(jsonEncode(mediaFileInfo.toJson())),
coverTask,
]);
if (curDownload.value?.cid != entry.cid) { if (curDownload.value?.cid != entry.cid) {
return; return;
@@ -380,28 +395,25 @@ class DownloadService extends GetxService {
_downloadManager = DownloadManager( _downloadManager = DownloadManager(
url: first.url, url: first.url,
path: path.join(videoDir.path, PathUtils.videoNameType1), path: path.join(videoDir.path, PathUtils.videoNameType1),
onTaskRunning: _onTaskRunning, onReceiveProgress: _onReceive,
onTaskComplete: _onTaskComplete, onDone: _onDone,
onTaskError: _onTaskError, );
)..start();
break; break;
case Type2 mediaFileInfo: case Type2 mediaFileInfo:
_downloadManager = DownloadManager( _downloadManager = DownloadManager(
url: mediaFileInfo.video.first.baseUrl, url: mediaFileInfo.video.first.baseUrl,
path: path.join(videoDir.path, PathUtils.videoNameType2), path: path.join(videoDir.path, PathUtils.videoNameType2),
onTaskRunning: _onTaskRunning, onReceiveProgress: _onReceive,
onTaskComplete: _onTaskComplete, onDone: _onDone,
onTaskError: _onTaskError, );
)..start();
final audio = mediaFileInfo.audio; final audio = mediaFileInfo.audio;
if (audio != null && audio.isNotEmpty) { if (audio != null && audio.isNotEmpty) {
_audioDownloadManager = DownloadManager( _audioDownloadManager = DownloadManager(
url: audio.first.baseUrl, url: audio.first.baseUrl,
path: path.join(videoDir.path, PathUtils.audioNameType2), path: path.join(videoDir.path, PathUtils.audioNameType2),
onTaskRunning: _onAudioTaskRunning, onReceiveProgress: null,
onTaskComplete: _onAudioTaskComplete, onDone: _onAudioDone,
onTaskError: _onAudioTaskError, );
)..start();
} }
late final first = mediaFileInfo.video.first; late final first = mediaFileInfo.video.first;
entry.pageData entry.pageData
@@ -425,17 +437,14 @@ class DownloadService extends GetxService {
Future<void> _updateBiliDownloadEntryJson(BiliDownloadEntryInfo entry) async { Future<void> _updateBiliDownloadEntryJson(BiliDownloadEntryInfo entry) async {
final entryJsonFile = File(path.join(entry.entryDirPath, _entryFile)); final entryJsonFile = File(path.join(entry.entryDirPath, _entryFile));
final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); await entryJsonFile.writeAsString(jsonEncode(entry.toJson()));
await entryJsonFile.writeAsString(entryJsonStr);
} }
void _onTaskRunning({required int progress, required int total}) { void _onReceive(int progress, int total) {
if (progress == 0 && total != 0) {
if (curDownload.value case final curEntryInfo?) {
_updateBiliDownloadEntryJson(curEntryInfo..totalBytes = total);
}
}
if (curDownload.value case final entry?) { if (curDownload.value case final entry?) {
if (progress == 0 && total != 0) {
_updateBiliDownloadEntryJson(entry..totalBytes = total);
}
entry entry
..downloadedBytes = progress ..downloadedBytes = progress
..status = DownloadStatus.downloading; ..status = DownloadStatus.downloading;
@@ -443,55 +452,38 @@ class DownloadService extends GetxService {
} }
} }
void _onTaskComplete() { void _onDone([Object? error]) {
final audioStatus = _audioDownloadManager?.status; final status = switch (_audioDownloadManager?.status) {
final status = switch (audioStatus) {
DownloadStatus.downloading => DownloadStatus.audioDownloading, DownloadStatus.downloading => DownloadStatus.audioDownloading,
DownloadStatus.failDownload => DownloadStatus.failDownloadAudio, DownloadStatus.failDownload => DownloadStatus.failDownloadAudio,
null => DownloadStatus.completed, _ => _downloadManager?.status ?? DownloadStatus.pause,
_ => audioStatus,
}; };
_updateCurStatus(status); _updateCurStatus(status);
if (status == DownloadStatus.completed) {
_completeDownload(); if (curDownload.value case final curEntryInfo?) {
} else { if (error == null) {
if (curDownload.value case final curEntryInfo?) { curEntryInfo.downloadedBytes = curEntryInfo.totalBytes;
_updateBiliDownloadEntryJson( }
curEntryInfo..downloadedBytes = curEntryInfo.totalBytes, if (status == DownloadStatus.completed) {
); _completeDownload();
} else {
_updateBiliDownloadEntryJson(curEntryInfo);
} }
} }
} }
void _onTaskError({ void _onAudioDone([Object? error]) {
required int progress,
required int total,
required Object error,
}) {
_updateCurStatus(DownloadStatus.failDownload);
if (curDownload.value case final curEntryInfo?) {
curEntryInfo
..totalBytes = total
..downloadedBytes = progress;
_updateBiliDownloadEntryJson(curEntryInfo);
}
}
void _onAudioTaskRunning({required int progress, required int total}) {}
void _onAudioTaskComplete() {
if (_downloadManager?.status == DownloadStatus.completed) { if (_downloadManager?.status == DownloadStatus.completed) {
_completeDownload(); if (error == null) {
} _completeDownload();
} } else {
final status = _audioDownloadManager?.status ?? DownloadStatus.pause;
void _onAudioTaskError({ _updateCurStatus(
required int progress, status == DownloadStatus.failDownload
required int total, ? DownloadStatus.failDownloadAudio
required Object error, : status,
}) { );
if (_downloadManager?.status == DownloadStatus.completed) { }
_updateCurStatus(DownloadStatus.failDownloadAudio);
} }
} }
@@ -504,7 +496,7 @@ class DownloadService extends GetxService {
..downloadedBytes = entry.totalBytes ..downloadedBytes = entry.totalBytes
..isCompleted = true; ..isCompleted = true;
await _updateBiliDownloadEntryJson(entry); await _updateBiliDownloadEntryJson(entry);
downloaFlag.refresh(); flagNotifier.refresh();
curDownload.value = null; curDownload.value = null;
_downloadManager = null; _downloadManager = null;
_audioDownloadManager = null; _audioDownloadManager = null;
@@ -514,7 +506,7 @@ class DownloadService extends GetxService {
void _nextDownload() { void _nextDownload() {
if (waitDownloadQueue.isNotEmpty) { if (waitDownloadQueue.isNotEmpty) {
final next = waitDownloadQueue.removeAt(0); final next = waitDownloadQueue.removeAt(0);
if (downloadList.contains(next)) { if (!next.isCompleted && downloadList.contains(next)) {
startDownload(next); startDownload(next);
} else { } else {
_nextDownload(); _nextDownload();
@@ -530,7 +522,7 @@ class DownloadService extends GetxService {
} }
final downloadDir = Directory(entry.pageDirPath); final downloadDir = Directory(entry.pageDirPath);
if (downloadDir.existsSync()) { if (downloadDir.existsSync()) {
if (downloadDir.listSync().length <= 1) { if (!await downloadDir.lengthGte(2)) {
await downloadDir.tryDel(recursive: true); await downloadDir.tryDel(recursive: true);
} else { } else {
final entryDir = Directory(entry.entryDirPath); final entryDir = Directory(entry.entryDirPath);
@@ -541,13 +533,13 @@ class DownloadService extends GetxService {
} }
downloadList.remove(entry); downloadList.remove(entry);
waitDownloadQueue.remove(entry); waitDownloadQueue.remove(entry);
downloaFlag.refresh(); flagNotifier.refresh();
} }
Future<void> deletePage({required String pageDirPath}) async { Future<void> deletePage({required String pageDirPath}) async {
await Directory(pageDirPath).tryDel(recursive: true); await Directory(pageDirPath).tryDel(recursive: true);
downloadList.removeWhere((e) => e.pageDirPath == pageDirPath); downloadList.removeWhere((e) => e.pageDirPath == pageDirPath);
downloaFlag.refresh(); flagNotifier.refresh();
} }
Future<void> cancelDownload({ Future<void> cancelDownload({
@@ -574,3 +566,11 @@ class DownloadService extends GetxService {
} }
} }
} }
extension on Set<void Function()> {
void refresh() {
for (var i in this) {
i();
}
}
}

View File

@@ -1,5 +1,6 @@
abstract class Em { abstract class Em {
static final _exp = RegExp('<[^>]*>([^<]*)</[^>]*>'); static final _exp = RegExp('<[^>]*>([^<]*)</[^>]*>');
static final _htmlRegExp = RegExp(r'&(lt|gt|quot|apos|nbsp|amp);');
static String regCate(String origin) { static String regCate(String origin) {
Iterable<Match> matches = _exp.allMatches(origin); Iterable<Match> matches = _exp.allMatches(origin);
@@ -17,14 +18,21 @@ abstract class Em {
}, },
onNonMatch: (String str) { onNonMatch: (String str) {
if (str != '') { if (str != '') {
str = str res.add((
.replaceAll('&lt;', '<') isEm: false,
.replaceAll('&gt;', '>') text: str.replaceAllMapped(
.replaceAll('&quot;', '"') _htmlRegExp,
.replaceAll('&apos;', "'") (m) => switch (m.group(1)) {
.replaceAll('&nbsp;', " ") 'lt' => '<',
.replaceAll('&amp;', "&"); 'gt' => '>',
res.add((isEm: false, text: str)); 'quot' => '"',
'apos' => "'",
'nbsp' => ' ',
'amp' => '&',
_ => m.group(0)!,
},
),
));
} }
return ''; return '';
}, },

View File

@@ -260,6 +260,14 @@ extension DirectoryExt on Directory {
await delete(recursive: recursive); await delete(recursive: recursive);
} catch (_) {} } catch (_) {}
} }
Future<bool> lengthGte(int length) async {
int count = 0;
await for (var _ in list()) {
if (++count == length) return true;
}
return false;
}
} }
extension SizeExt on Size { extension SizeExt on Size {

View File

@@ -16,6 +16,8 @@ abstract final class PathUtils {
static const _fileExt = '.m4s'; static const _fileExt = '.m4s';
static const audioNameType2 = 'audio$_fileExt'; static const audioNameType2 = 'audio$_fileExt';
static const videoNameType2 = 'video$_fileExt'; static const videoNameType2 = 'video$_fileExt';
static const coverName = 'cover.jpg';
static const danmakuName = 'danmaku.pb';
static const downloadDir = 'download'; static const downloadDir = 'download';
static String buildShadersAbsolutePath( static String buildShadersAbsolutePath(

View File

@@ -1996,7 +1996,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: "direct main" dependency: transitive
description: description:
name: xml name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"

View File

@@ -220,7 +220,6 @@ dependencies:
git: git:
url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git
ref: mod ref: mod
xml: ^6.6.1
dlna_dart: ^0.1.0 dlna_dart: ^0.1.0
vector_math: any vector_math: any