From 407b31c5c169430ba4e3654d62ae38ac1bad5b13 Mon Sep 17 00:00:00 2001 From: My-Responsitories <107370289+My-Responsitories@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:12:17 +0800 Subject: [PATCH] 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 --------- Co-authored-by: bggRGjQaUbCoE --- .../download/bili_download_entry_info.dart | 119 ++++----- lib/pages/danmaku/controller.dart | 80 ++---- lib/pages/danmaku/view.dart | 4 +- lib/pages/download/controller.dart | 7 +- lib/pages/download/detail/widgets/item.dart | 39 ++- lib/pages/setting/models/extra_settings.dart | 4 +- lib/pages/video/download_panel/view.dart | 3 +- lib/pages/video/introduction/local/view.dart | 33 ++- lib/pages/video/view.dart | 2 +- lib/services/download/download_manager.dart | 195 ++++----------- lib/services/download/download_service.dart | 232 +++++++++--------- lib/utils/em.dart | 24 +- lib/utils/extension.dart | 8 + lib/utils/path_utils.dart | 2 + pubspec.lock | 2 +- pubspec.yaml | 1 - 16 files changed, 345 insertions(+), 410 deletions(-) diff --git a/lib/models_new/download/bili_download_entry_info.dart b/lib/models_new/download/bili_download_entry_info.dart index 1d0a619b9..8312b5e70 100644 --- a/lib/models_new/download/bili_download_entry_info.dart +++ b/lib/models_new/download/bili_download_entry_info.dart @@ -119,66 +119,69 @@ class BiliDownloadEntryInfo { required DownloadService downloadService, required bool isPage, }) { - return Obx(() { - final curDownload = downloadService.curDownload.value; - final isCurr = - curDownload != null && - (isPage ? curDownload.pageId == pageId : curDownload.cid == cid); - late final status = curDownload?.status; - late final isDownloading = status == DownloadStatus.downloading; - late final color = isCurr && status != DownloadStatus.pause - ? theme.colorScheme.primary - : theme.colorScheme.outline; - return Column( - spacing: 6, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - isCurr ? status!.message : '暂停中', - style: TextStyle( - fontSize: 12, - height: 1, - color: color, + return RepaintBoundary( + // TODO: refresh `downloadedBytes` only cause unnecessary repaint + child: Obx(() { + final curDownload = downloadService.curDownload.value; + final isCurr = + curDownload != null && + (isPage ? curDownload.pageId == pageId : curDownload.cid == cid); + late final status = curDownload?.status; + late final isDownloading = status == DownloadStatus.downloading; + late final color = isCurr && status != DownloadStatus.pause + ? theme.colorScheme.primary + : theme.colorScheme.outline; + return Column( + spacing: 6, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isCurr ? status!.message : '暂停中', + style: TextStyle( + fontSize: 12, + height: 1, + color: color, + ), ), - ), - Text( - isCurr - ? isDownloading || status == DownloadStatus.pause - ? ' ${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}' - : '' - : totalBytes == 0 - ? '' - : ' ${CacheManager.formatSize(downloadedBytes)}/${CacheManager.formatSize(totalBytes)}', - style: TextStyle( - fontSize: 12, - height: 1, - color: color, + Text( + isCurr + ? isDownloading || status == DownloadStatus.pause + ? ' ${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}' + : '' + : totalBytes == 0 + ? '' + : ' ${CacheManager.formatSize(downloadedBytes)}/${CacheManager.formatSize(totalBytes)}', + style: TextStyle( + fontSize: 12, + height: 1, + color: color, + ), ), - ), - ], - ), - LinearProgressIndicator( - // ignore: deprecated_member_use - year2023: true, - minHeight: 2.5, - borderRadius: StyleString.mdRadius, - color: color, - backgroundColor: theme.highlightColor, - value: isCurr - ? curDownload.totalBytes == 0 - ? 0 - : curDownload.downloadedBytes / curDownload.totalBytes - : totalBytes == 0 - ? 0 - : downloadedBytes / totalBytes, - ), - ], - ); - }); + ], + ), + LinearProgressIndicator( + // ignore: deprecated_member_use + year2023: true, + minHeight: 2.5, + borderRadius: StyleString.mdRadius, + color: color, + backgroundColor: theme.highlightColor, + value: isCurr + ? curDownload.totalBytes == 0 + ? 0 + : curDownload.downloadedBytes / curDownload.totalBytes + : totalBytes == 0 + ? 0 + : downloadedBytes / totalBytes, + ), + ], + ); + }), + ); } BiliDownloadEntryInfo({ diff --git a/lib/pages/danmaku/controller.dart b/lib/pages/danmaku/controller.dart index c071d7fdb..8312a8472 100644 --- a/lib/pages/danmaku/controller.dart +++ b/lib/pages/danmaku/controller.dart @@ -1,14 +1,12 @@ -import 'dart:convert'; import 'dart:io' show File; import 'package:PiliPlus/grpc/bilibili/community/service/dm/v1.pb.dart'; import 'package:PiliPlus/grpc/dm.dart'; import 'package:PiliPlus/plugin/pl_player/controller.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:path/path.dart' as path; -import 'package:xml/xml.dart'; class PlDanmakuController { PlDanmakuController( @@ -36,7 +34,7 @@ class PlDanmakuController { requestedSeg.clear(); } - int calcSegment(int progress) { + static int calcSegment(int progress) { return progress ~/ segmentLength; } @@ -102,7 +100,7 @@ class PlDanmakuController { List? getCurrentDanmaku(int progress) { if (isFileSource) { - initXmlDmIfNeeded(); + initFileDmIfNeeded(); } else { final int segmentIndex = calcSegment(progress); if (!requestedSeg.contains(segmentIndex)) { @@ -115,68 +113,24 @@ class PlDanmakuController { bool closed = false; - late bool _xmlDmLoaded = false; + bool _fileDmLoaded = false; - void initXmlDmIfNeeded() { - if (_xmlDmLoaded) return; - _xmlDmLoaded = true; - _initXmlDm(); + void initFileDmIfNeeded() { + if (_fileDmLoaded) return; + _fileDmLoaded = true; + _initFileDm(); } - Future _initXmlDm() async { + Future _initFileDm() async { try { - final file = File(path.join(plPlayerController.dirPath!, 'danmaku.xml')); - final stream = file.openRead().transform(utf8.decoder); - final buffer = StringBuffer(); - await for (final chunk in stream) { - if (closed) { - return; - } - buffer.write(chunk); - } - if (closed) { - return; - } - final xmlString = buffer.toString(); - final document = XmlDocument.parse(xmlString); - final danmakus = document.findAllElements('d').toList(); - final elems = []; - 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); + final file = File( + path.join(plPlayerController.dirPath!, PathUtils.danmakuName), + ); + if (!file.existsSync()) return; + final bytes = await file.readAsBytes(); + if (bytes.isEmpty) return; + final elem = DmSegMobileReply.fromBuffer(bytes).elems; + handleDanmaku(elem); } catch (_) { if (kDebugMode) rethrow; } diff --git a/lib/pages/danmaku/view.dart b/lib/pages/danmaku/view.dart index 173d467fd..414d9db06 100644 --- a/lib/pages/danmaku/view.dart +++ b/lib/pages/danmaku/view.dart @@ -48,10 +48,10 @@ class _PlDanmakuState extends State { ); if (playerController.enableShowDanmaku.value) { if (widget.isFileSource) { - _plDanmakuController.initXmlDmIfNeeded(); + _plDanmakuController.initFileDmIfNeeded(); } else { _plDanmakuController.queryDanmaku( - _plDanmakuController.calcSegment( + PlDanmakuController.calcSegment( playerController.position.value.inMilliseconds, ), ); diff --git a/lib/pages/download/controller.dart b/lib/pages/download/controller.dart index ff6b2f071..6decffc11 100644 --- a/lib/pages/download/controller.dart +++ b/lib/pages/download/controller.dart @@ -6,7 +6,6 @@ import 'package:get/get.dart'; class DownloadPageController extends GetxController { final _downloadService = Get.find(); - late final StreamSubscription _sub; final pages = RxList(); final flag = RxInt(0); @@ -14,14 +13,12 @@ class DownloadPageController extends GetxController { void onInit() { super.onInit(); _loadList(); - _sub = _downloadService.downloaFlag.listen((_) { - _loadList(); - }); + _downloadService.flagNotifier.add(_loadList); } @override void onClose() { - _sub.cancel(); + _downloadService.flagNotifier.remove(_loadList); super.onClose(); } diff --git a/lib/pages/download/detail/widgets/item.dart b/lib/pages/download/detail/widgets/item.dart index 1552b48c8..78d60f58f 100644 --- a/lib/pages/download/detail/widgets/item.dart +++ b/lib/pages/download/detail/widgets/item.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/badge.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/utils/cache_manager.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/path_utils.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; +import 'package:path/path.dart' as path; class DetailItem extends StatelessWidget { const DetailItem({ @@ -141,11 +146,35 @@ class DetailItem extends StatelessWidget { AspectRatio( aspectRatio: StyleString.aspectRatio, child: LayoutBuilder( - builder: (context, constraints) => NetworkImgLayer( - src: entry.cover, - width: constraints.maxWidth, - height: constraints.maxHeight, - ), + builder: (context, constraints) { + final cover = File( + path.join(entry.entryDirPath, PathUtils.coverName), + ); + 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?) diff --git a/lib/pages/setting/models/extra_settings.dart b/lib/pages/setting/models/extra_settings.dart index 8205b918e..406674f05 100644 --- a/lib/pages/setting/models/extra_settings.dart +++ b/lib/pages/setting/models/extra_settings.dart @@ -89,7 +89,7 @@ List get extraSettings => [ if (downloadPath == defPath) return; downloadPath = defPath; setState(); - Get.find().readDownloadList(); + Get.find().initDownloadList(); GStorage.setting.delete(SettingBoxKey.downloadPath); }, dense: true, @@ -102,7 +102,7 @@ List get extraSettings => [ if (path == null || path == downloadPath) return; downloadPath = path; setState(); - Get.find().readDownloadList(); + Get.find().initDownloadList(); GStorage.setting.put(SettingBoxKey.downloadPath, path); }, dense: true, diff --git a/lib/pages/video/download_panel/view.dart b/lib/pages/video/download_panel/view.dart index 4f4da826d..5e7b83ea3 100644 --- a/lib/pages/video/download_panel/view.dart +++ b/lib/pages/video/download_panel/view.dart @@ -130,9 +130,10 @@ class _DownloadPanelState extends State { style: const TextStyle(height: 1), strutStyle: const StrutStyle(height: 1, leading: 0), ), - const Icon( + Icon( size: 18, Icons.keyboard_arrow_down, + color: theme.colorScheme.onSurfaceVariant, ), ], ), diff --git a/lib/pages/video/introduction/local/view.dart b/lib/pages/video/introduction/local/view.dart index 0acd47999..932e07e38 100644 --- a/lib/pages/video/introduction/local/view.dart +++ b/lib/pages/video/introduction/local/view.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/badge.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/pages/video/introduction/local/controller.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:get/get.dart'; +import 'package:path/path.dart' as path; class LocalIntroPanel extends StatefulWidget { const LocalIntroPanel({super.key, required this.heroTag}); @@ -49,6 +54,7 @@ class _LocalIntroPanelState extends State BiliDownloadEntryInfo entry, ) { final outline = theme.colorScheme.outline; + final cover = File(path.join(entry.entryDirPath, PathUtils.coverName)); return Padding( padding: const EdgeInsets.only(bottom: 2), child: SizedBox( @@ -73,11 +79,28 @@ class _LocalIntroPanelState extends State Stack( clipBehavior: Clip.none, children: [ - NetworkImgLayer( - src: entry.cover, - width: 140.8, - height: 88, - ), + cover.existsSync() + ? ClipRRect( + borderRadius: StyleString.mdRadius, + 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( text: DurationUtils.formatDuration( entry.totalTimeMilli ~/ 1000, diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index 869a6beb7..49eba350d 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -1308,7 +1308,7 @@ class _VideoDetailPageVState extends State ), if (!videoDetailController.isFileSource) PopupMenuItem( - onTap: () => videoDetailController.onDownload(context), + onTap: () => videoDetailController.onDownload(this.context), child: const Text('缓存视频'), ), if (videoDetailController.cover.value.isNotEmpty) diff --git a/lib/services/download/download_manager.dart b/lib/services/download/download_manager.dart index e8327e121..ec2c7abce 100644 --- a/lib/services/download/download_manager.dart +++ b/lib/services/download/download_manager.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Completer, StreamSubscription; +import 'dart:async'; import 'dart:io'; import 'package:PiliPlus/http/init.dart'; @@ -9,186 +9,97 @@ import 'package:dio/dio.dart'; class DownloadManager { final String url; final String path; - final Function({required int progress, required int total}) onTaskRunning; - final Function() onTaskComplete; - final Function({ - required int progress, - required int total, - required Object error, - }) - onTaskError; + final void Function(int, int)? onReceiveProgress; + final void Function([Object? error]) onDone; - bool _closed = false; - DownloadStatus _status = DownloadStatus.wait; + DownloadStatus _status = DownloadStatus.downloading; DownloadStatus get status => _status; - CancelToken? _cancelToken; - Completer? _completer; + final _cancelToken = CancelToken(); + late Future task; DownloadManager({ required this.url, required this.path, - required this.onTaskRunning, - required this.onTaskComplete, - required this.onTaskError, - }); - - void _complete() { - if (_completer?.isCompleted == false) { - _completer?.complete(); - } + required this.onReceiveProgress, + required this.onDone, + }) { + task = _start(); } - Future start() async { - _completer = Completer(); - _cancelToken = CancelToken(); - _status = DownloadStatus.downloading; + Future _start() async { + int received; 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); + received = 0; } - final int downloadedSize = await file.length(); - - // 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, + final sink = file.openWrite( + mode: received == 0 ? FileMode.writeOnly : FileMode.writeOnlyAppend, ); - Future? asyncWrite; - Future closeAndDelete({bool delete = false}) async { - if (!_closed) { - _closed = true; - await asyncWrite; - await raf.close().catchError((_) => raf); + Future onError(Object e, {bool delete = false}) async { + try { + await sink.close(); + } catch (_) {} + if (_status == DownloadStatus.downloading) { + _status = DownloadStatus.failDownload; if (delete && file.existsSync()) { - await file.delete().catchError((_) => file); + await file.tryDel(); } } + onDone(e); } - final Response response; + Response response; try { response = await Request.dio.get( url.http2https, options: Options( - headers: {'range': 'bytes=$downloadedSize-'}, + headers: {'range': 'bytes=$received-'}, responseType: ResponseType.stream, - validateStatus: (status) { - return status == 416 || - (status != null && status >= 200 && status < 300); - }, + validateStatus: (status) => + status != null && + (status == 416 || (status >= 200 && status < 300)), ), cancelToken: _cancelToken, ); } on DioException catch (e) { - final isFailed = e.response?.statusCode != 416; - if (isFailed) { - _status = DownloadStatus.failDownload; - onTaskError(progress: 0, total: 0, error: e); - } else { - _status = DownloadStatus.completed; - onTaskComplete(); - } - closeAndDelete(delete: isFailed).whenComplete(_complete); + await onError(e, delete: true); return; } + final data = response.data!; + final contentLength = data.contentLength + received; - int received = downloadedSize; - - // Stream - final stream = response.data!.stream; - - final total = - int.parse(response.headers.value(Headers.contentLengthHeader) ?? '0') + - downloadedSize; - - if (downloadedSize == 0) { - onTaskRunning(progress: 0, total: total); + if (received == 0) { + onReceiveProgress?.call(0, contentLength); } - late StreamSubscription subscription; - subscription = stream.listen( - (data) { - subscription.pause(); - // Write file asynchronously - asyncWrite = raf - .writeFrom(data) - .then((result) async { - // Notify progress - received += data.length; - onTaskRunning(progress: received, total: total); - - 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? _cancel() { - if (_cancelToken != null) { - _cancelToken?.cancel(); - _cancelToken = null; + try { + await for (final chunk in data.stream) { + sink.add(chunk); + received += chunk.length; + onReceiveProgress?.call(received, contentLength); + } + await sink.close(); + _status = DownloadStatus.completed; + onDone(); + } catch (e) { + await onError(e); + return; } - return _completer?.future; } - Future? cancel({required bool isDelete}) { + Future cancel({required bool isDelete}) { if (!isDelete && _status == DownloadStatus.downloading) { _status = DownloadStatus.pause; } - return _cancel(); + if (!_cancelToken.isCancelled) { + _cancelToken.cancel(); + } + return task; } } diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index 28cefe9b9..b96186a45 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -1,7 +1,8 @@ import 'dart:async'; -import 'dart:convert' show jsonDecode, utf8; -import 'dart:io' show Directory, File, FileSystemEntity; +import 'dart:convert' show jsonDecode, jsonEncode; +import 'dart:io' show Directory, File; +import 'package:PiliPlus/grpc/dm.dart'; import 'package:PiliPlus/http/download.dart'; import 'package:PiliPlus/http/init.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/episode.dart' as ugc; 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/utils/extension.dart'; import 'package:PiliPlus/utils/id_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_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:path/path.dart' as path; @@ -30,12 +30,10 @@ import 'package:synchronized/synchronized.dart'; class DownloadService extends GetxService { static const _entryFile = 'entry.json'; static const _indexFile = 'index.json'; - static const _danmakuFile = 'danmaku.xml'; - static const _coverFile = 'cover.jpg'; final _lock = Lock(); - final downloaFlag = RxnInt(); + final flagNotifier = {}; final waitDownloadQueue = []; final downloadList = RxList(); @@ -51,41 +49,40 @@ class DownloadService extends GetxService { DownloadManager? _downloadManager; DownloadManager? _audioDownloadManager; - Completer? _completer; - Future? get waitForInitialization => _completer?.future; + late Future waitForInitialization; @override void onInit() { super.onInit(); - readDownloadList(); + initDownloadList(); } - Future readDownloadList() async { - _completer = Completer(); + void initDownloadList() { + waitForInitialization = _readDownloadList(); + } + + Future _readDownloadList() async { final downloadDir = Directory(await _getDownloadPath()); final list = []; - for (final dir in downloadDir.listSync()) { + await for (final dir in downloadDir.list()) { if (dir is Directory) { list.addAll(await _readDownloadDirectory(dir)); } } downloadList.value = list ..sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp)); - if (!_completer!.isCompleted) { - _completer!.complete(); - } } Future> _readDownloadDirectory( - FileSystemEntity pageDir, + Directory pageDir, ) async { final result = []; - if (!pageDir.existsSync() || pageDir is! Directory) { + if (!pageDir.existsSync()) { return result; } - for (final entryDir in pageDir.listSync()) { + await for (final entryDir in pageDir.list()) { if (entryDir is Directory) { final entryFile = File(path.join(entryDir.path, _entryFile)); if (entryFile.existsSync()) { @@ -96,7 +93,7 @@ class DownloadService extends GetxService { ..entryDirPath = entryDir.path; result.add(entry); if (!entry.isCompleted) { - waitDownloadQueue.add(entry); + waitDownloadQueue.add(entry..status = DownloadStatus.wait); } } catch (_) { if (kDebugMode) rethrow; @@ -141,7 +138,7 @@ class DownloadService extends GetxService { downloadedBytes: 0, title: videoDetail?.title ?? videoArc!.title!, typeTag: videoQuality.code.toString(), - cover: videoDetail?.pic ?? videoArc!.cover!, + cover: (videoDetail?.pic ?? videoArc!.cover!).http2https, preferedVideoQuality: videoQuality.code, qualityPithyDescription: videoQuality.desc, guessedTotalBytes: 0, @@ -234,14 +231,13 @@ class DownloadService extends GetxService { Future _createDownload(BiliDownloadEntryInfo entry) async { final entryDir = await _getDownloadEntryDir(entry); final entryJsonFile = File(path.join(entryDir.path, _entryFile)); - final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); - await entryJsonFile.writeAsBytes(utf8.encode(entryJsonStr)); + await entryJsonFile.writeAsString(jsonEncode(entry.toJson())); entry ..pageDirPath = entryDir.parent.path ..entryDirPath = entryDir.path ..status = DownloadStatus.wait; downloadList.insert(0, entry); - downloaFlag.refresh(); + flagNotifier.refresh(); final currStatus = curDownload.value?.status?.index; if (currStatus == null || currStatus > 3) { startDownload(entry); @@ -269,7 +265,7 @@ class DownloadService extends GetxService { return pageDir; } - Future _getDownloadPath() async { + static Future _getDownloadPath() async { final dir = Directory(downloadPath); if (!dir.existsSync()) { await dir.create(recursive: true); @@ -301,26 +297,38 @@ class DownloadService extends GetxService { if (cid == null) { return false; } - final danmakuXMLFile = File(path.join(entry.entryDirPath, _danmakuFile)); - if (isUpdate || !danmakuXMLFile.existsSync()) { + final danmakuFile = File( + path.join(entry.entryDirPath, PathUtils.danmakuName), + ); + if (isUpdate || !danmakuFile.existsSync()) { try { if (!isUpdate) { _updateCurStatus(DownloadStatus.getDanmaku); } - final res = await Request().get( - 'https://comment.bilibili.com/$cid.xml', - options: Options(responseType: ResponseType.bytes), - ); - final xmlBytes = Inflate((res.data as Uint8List)).getBytes(); - await danmakuXMLFile.writeAsBytes(xmlBytes); + final seg = (entry.totalTimeMilli / PlDanmakuController.segmentLength) + .ceil(); + + final res = await Future.wait([ + for (var i = 1; i <= seg; i++) + 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; } catch (e) { if (!isUpdate) { _updateCurStatus(DownloadStatus.failDanmaku); } - if (kDebugMode) { - SmartDialog.showToast(e.toString()); - } + if (kDebugMode) SmartDialog.showToast(e.toString()); return false; } } @@ -331,13 +339,22 @@ class DownloadService extends GetxService { required BiliDownloadEntryInfo entry, }) async { try { - await Request.dio.download( - entry.cover.http2https, - path.join(entry.entryDirPath, _coverFile), - ); + final filePath = path.join(entry.entryDirPath, PathUtils.coverName); + if (File(filePath).existsSync()) { + 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; - } catch (_) {} - return false; + } catch (_) { + return false; + } } Future _startDownload(BiliDownloadEntryInfo entry) async { @@ -357,18 +374,16 @@ class DownloadService extends GetxService { await videoDir.create(recursive: true); } - final res = await Future.wait([ - downloadDanmaku(entry: entry), - _downloadCover(entry: entry), - ]); + final coverTask = _downloadCover(entry: entry); - if (!res.first) { + if (!await downloadDanmaku(entry: entry)) { return; } - final mediaJsonFile = File(path.join(videoDir.path, _indexFile)); - final mediaJsonStr = Utils.jsonEncoder.convert(mediaFileInfo.toJson()); - await mediaJsonFile.writeAsString(mediaJsonStr); + await Future.wait([ + mediaJsonFile.writeAsString(jsonEncode(mediaFileInfo.toJson())), + coverTask, + ]); if (curDownload.value?.cid != entry.cid) { return; @@ -380,28 +395,25 @@ class DownloadService extends GetxService { _downloadManager = DownloadManager( url: first.url, path: path.join(videoDir.path, PathUtils.videoNameType1), - onTaskRunning: _onTaskRunning, - onTaskComplete: _onTaskComplete, - onTaskError: _onTaskError, - )..start(); + onReceiveProgress: _onReceive, + onDone: _onDone, + ); break; case Type2 mediaFileInfo: _downloadManager = DownloadManager( url: mediaFileInfo.video.first.baseUrl, path: path.join(videoDir.path, PathUtils.videoNameType2), - onTaskRunning: _onTaskRunning, - onTaskComplete: _onTaskComplete, - onTaskError: _onTaskError, - )..start(); + onReceiveProgress: _onReceive, + onDone: _onDone, + ); final audio = mediaFileInfo.audio; if (audio != null && audio.isNotEmpty) { _audioDownloadManager = DownloadManager( url: audio.first.baseUrl, path: path.join(videoDir.path, PathUtils.audioNameType2), - onTaskRunning: _onAudioTaskRunning, - onTaskComplete: _onAudioTaskComplete, - onTaskError: _onAudioTaskError, - )..start(); + onReceiveProgress: null, + onDone: _onAudioDone, + ); } late final first = mediaFileInfo.video.first; entry.pageData @@ -425,17 +437,14 @@ class DownloadService extends GetxService { Future _updateBiliDownloadEntryJson(BiliDownloadEntryInfo entry) async { final entryJsonFile = File(path.join(entry.entryDirPath, _entryFile)); - final entryJsonStr = Utils.jsonEncoder.convert(entry.toJson()); - await entryJsonFile.writeAsString(entryJsonStr); + await entryJsonFile.writeAsString(jsonEncode(entry.toJson())); } - void _onTaskRunning({required int progress, required int total}) { - if (progress == 0 && total != 0) { - if (curDownload.value case final curEntryInfo?) { - _updateBiliDownloadEntryJson(curEntryInfo..totalBytes = total); - } - } + void _onReceive(int progress, int total) { if (curDownload.value case final entry?) { + if (progress == 0 && total != 0) { + _updateBiliDownloadEntryJson(entry..totalBytes = total); + } entry ..downloadedBytes = progress ..status = DownloadStatus.downloading; @@ -443,55 +452,38 @@ class DownloadService extends GetxService { } } - void _onTaskComplete() { - final audioStatus = _audioDownloadManager?.status; - final status = switch (audioStatus) { + void _onDone([Object? error]) { + final status = switch (_audioDownloadManager?.status) { DownloadStatus.downloading => DownloadStatus.audioDownloading, DownloadStatus.failDownload => DownloadStatus.failDownloadAudio, - null => DownloadStatus.completed, - _ => audioStatus, + _ => _downloadManager?.status ?? DownloadStatus.pause, }; _updateCurStatus(status); - if (status == DownloadStatus.completed) { - _completeDownload(); - } else { - if (curDownload.value case final curEntryInfo?) { - _updateBiliDownloadEntryJson( - curEntryInfo..downloadedBytes = curEntryInfo.totalBytes, - ); + + if (curDownload.value case final curEntryInfo?) { + if (error == null) { + curEntryInfo.downloadedBytes = curEntryInfo.totalBytes; + } + if (status == DownloadStatus.completed) { + _completeDownload(); + } else { + _updateBiliDownloadEntryJson(curEntryInfo); } } } - void _onTaskError({ - 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() { + void _onAudioDone([Object? error]) { if (_downloadManager?.status == DownloadStatus.completed) { - _completeDownload(); - } - } - - void _onAudioTaskError({ - required int progress, - required int total, - required Object error, - }) { - if (_downloadManager?.status == DownloadStatus.completed) { - _updateCurStatus(DownloadStatus.failDownloadAudio); + if (error == null) { + _completeDownload(); + } else { + final status = _audioDownloadManager?.status ?? DownloadStatus.pause; + _updateCurStatus( + status == DownloadStatus.failDownload + ? DownloadStatus.failDownloadAudio + : status, + ); + } } } @@ -504,7 +496,7 @@ class DownloadService extends GetxService { ..downloadedBytes = entry.totalBytes ..isCompleted = true; await _updateBiliDownloadEntryJson(entry); - downloaFlag.refresh(); + flagNotifier.refresh(); curDownload.value = null; _downloadManager = null; _audioDownloadManager = null; @@ -514,7 +506,7 @@ class DownloadService extends GetxService { void _nextDownload() { if (waitDownloadQueue.isNotEmpty) { final next = waitDownloadQueue.removeAt(0); - if (downloadList.contains(next)) { + if (!next.isCompleted && downloadList.contains(next)) { startDownload(next); } else { _nextDownload(); @@ -530,7 +522,7 @@ class DownloadService extends GetxService { } final downloadDir = Directory(entry.pageDirPath); if (downloadDir.existsSync()) { - if (downloadDir.listSync().length <= 1) { + if (!await downloadDir.lengthGte(2)) { await downloadDir.tryDel(recursive: true); } else { final entryDir = Directory(entry.entryDirPath); @@ -541,13 +533,13 @@ class DownloadService extends GetxService { } downloadList.remove(entry); waitDownloadQueue.remove(entry); - downloaFlag.refresh(); + flagNotifier.refresh(); } Future deletePage({required String pageDirPath}) async { await Directory(pageDirPath).tryDel(recursive: true); downloadList.removeWhere((e) => e.pageDirPath == pageDirPath); - downloaFlag.refresh(); + flagNotifier.refresh(); } Future cancelDownload({ @@ -574,3 +566,11 @@ class DownloadService extends GetxService { } } } + +extension on Set { + void refresh() { + for (var i in this) { + i(); + } + } +} diff --git a/lib/utils/em.dart b/lib/utils/em.dart index 3b7c7dd7f..5febcc82f 100644 --- a/lib/utils/em.dart +++ b/lib/utils/em.dart @@ -1,5 +1,6 @@ abstract class Em { static final _exp = RegExp('<[^>]*>([^<]*)]*>'); + static final _htmlRegExp = RegExp(r'&(lt|gt|quot|apos|nbsp|amp);'); static String regCate(String origin) { Iterable matches = _exp.allMatches(origin); @@ -17,14 +18,21 @@ abstract class Em { }, onNonMatch: (String str) { if (str != '') { - str = str - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll(' ', " ") - .replaceAll('&', "&"); - res.add((isEm: false, text: str)); + res.add(( + isEm: false, + text: str.replaceAllMapped( + _htmlRegExp, + (m) => switch (m.group(1)) { + 'lt' => '<', + 'gt' => '>', + 'quot' => '"', + 'apos' => "'", + 'nbsp' => ' ', + 'amp' => '&', + _ => m.group(0)!, + }, + ), + )); } return ''; }, diff --git a/lib/utils/extension.dart b/lib/utils/extension.dart index 97045f181..6544555d9 100644 --- a/lib/utils/extension.dart +++ b/lib/utils/extension.dart @@ -260,6 +260,14 @@ extension DirectoryExt on Directory { await delete(recursive: recursive); } catch (_) {} } + + Future lengthGte(int length) async { + int count = 0; + await for (var _ in list()) { + if (++count == length) return true; + } + return false; + } } extension SizeExt on Size { diff --git a/lib/utils/path_utils.dart b/lib/utils/path_utils.dart index 9efb3d5a4..f80e41362 100644 --- a/lib/utils/path_utils.dart +++ b/lib/utils/path_utils.dart @@ -16,6 +16,8 @@ abstract final class PathUtils { static const _fileExt = '.m4s'; static const audioNameType2 = 'audio$_fileExt'; static const videoNameType2 = 'video$_fileExt'; + static const coverName = 'cover.jpg'; + static const danmakuName = 'danmaku.pb'; static const downloadDir = 'download'; static String buildShadersAbsolutePath( diff --git a/pubspec.lock b/pubspec.lock index bad545b97..951223728 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1996,7 +1996,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: "direct main" + dependency: transitive description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/pubspec.yaml b/pubspec.yaml index f7af17075..cd90ac06e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -220,7 +220,6 @@ dependencies: git: url: https://github.com/bggRGjQaUbCoE/super_sliver_list.git ref: mod - xml: ^6.6.1 dlna_dart: ^0.1.0 vector_math: any