diff --git a/lib/common/widgets/appbar/appbar.dart b/lib/common/widgets/appbar/appbar.dart index c640647f7..368a96b3f 100644 --- a/lib/common/widgets/appbar/appbar.dart +++ b/lib/common/widgets/appbar/appbar.dart @@ -41,7 +41,12 @@ class MultiSelectAppBarWidget extends StatelessWidget style: TextButton.styleFrom( visualDensity: VisualDensity.compact, ), - onPressed: ctr.onRemove, + onPressed: () { + if (ctr.checkedCount == 0) { + return; + } + ctr.onRemove(); + }, child: Text( '移除', style: TextStyle(color: Get.theme.colorScheme.error), diff --git a/lib/common/widgets/dialog/dialog.dart b/lib/common/widgets/dialog/dialog.dart index 7ad1a0f70..edd9a713f 100644 --- a/lib/common/widgets/dialog/dialog.dart +++ b/lib/common/widgets/dialog/dialog.dart @@ -5,7 +5,7 @@ Future showConfirmDialog({ required BuildContext context, required String title, Object? content, - @Deprecated('use `bool result = await showConfirmDialog()` instead') + // @Deprecated('use `bool result = await showConfirmDialog()` instead') VoidCallback? onConfirm, }) async { assert(content is String? || content is Widget); diff --git a/lib/models_new/download/bili_download_entry_info.dart b/lib/models_new/download/bili_download_entry_info.dart index cfc884e8b..1181a0820 100644 --- a/lib/models_new/download/bili_download_entry_info.dart +++ b/lib/models_new/download/bili_download_entry_info.dart @@ -1,13 +1,11 @@ -import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/models/common/video/video_type.dart'; -import 'package:PiliPlus/services/download/download_service.dart'; -import 'package:PiliPlus/utils/cache_manager.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show MultiSelectData; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:flutter/material.dart'; -import 'package:get/get_state_manager/src/rx_flutter/rx_obx_widget.dart'; import 'package:get/route_manager.dart'; -class BiliDownloadEntryInfo { +class BiliDownloadEntryInfo with MultiSelectData { int mediaType; final bool hasDashAudio; bool isCompleted; @@ -114,76 +112,6 @@ class BiliDownloadEntryInfo { ), ); - Widget progressWidget({ - required ThemeData theme, - required DownloadService downloadService, - required bool isPage, - }) { - 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, - ), - ), - ], - ), - 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({ this.mediaType = 1, this.hasDashAudio = false, diff --git a/lib/models_new/download/download_info.dart b/lib/models_new/download/download_info.dart index cde637901..d7a497fac 100644 --- a/lib/models_new/download/download_info.dart +++ b/lib/models_new/download/download_info.dart @@ -1,6 +1,8 @@ import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show MultiSelectData; -class DownloadPageInfo { +class DownloadPageInfo with MultiSelectData { final String pageId; final String dirPath; final String title; @@ -8,7 +10,6 @@ class DownloadPageInfo { int sortKey; final int? seasonType; final List entrys; - BiliDownloadEntryInfo? entry; DownloadPageInfo({ required this.pageId, @@ -18,6 +19,5 @@ class DownloadPageInfo { required this.sortKey, this.seasonType, required this.entrys, - this.entry, }); } diff --git a/lib/pages/common/multi_select/base.dart b/lib/pages/common/multi_select/base.dart index 2d76b839f..7910c1a47 100644 --- a/lib/pages/common/multi_select/base.dart +++ b/lib/pages/common/multi_select/base.dart @@ -6,7 +6,7 @@ mixin MultiSelectData { bool? checked; } -abstract class MultiSelectBase { +mixin MultiSelectBase { RxBool get enableMultiSelect; RxBool get allSelected; @@ -17,6 +17,52 @@ abstract class MultiSelectBase { void onRemove(); } +mixin BaseMultiSelectMixin + implements MultiSelectBase { + @override + late final allSelected = false.obs; + + late final RxInt rxCount = 0.obs; + @override + int get checkedCount => rxCount.value; + + @override + final RxBool enableMultiSelect = false.obs; + + RxObjectMixin get state; + List get list; + + Iterable get allChecked => list.where((v) => v.checked == true); + + @override + void handleSelect({bool checked = false, bool disableSelect = true}) { + for (var item in list) { + item.checked = checked; + } + state.refresh(); + rxCount.value = checked ? list.length : 0; + if (disableSelect && !checked) { + enableMultiSelect.value = false; + } + } + + @override + void onSelect(T item) { + item.checked = !(item.checked ?? false); + if (item.checked!) { + rxCount.value++; + } else { + rxCount.value--; + } + state.refresh(); + if (checkedCount == 0) { + enableMultiSelect.value = false; + } else { + allSelected.value = checkedCount == list.length; + } + } +} + mixin CommonMultiSelectMixin implements MultiSelectBase { @override diff --git a/lib/pages/common/search/common_search_page.dart b/lib/pages/common/search/common_search_page.dart index 7bf9c8891..47bfd0278 100644 --- a/lib/pages/common/search/common_search_page.dart +++ b/lib/pages/common/search/common_search_page.dart @@ -7,11 +7,7 @@ import 'package:PiliPlus/pages/common/search/common_search_controller.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -abstract class CommonSearchPage extends StatefulWidget { - const CommonSearchPage({super.key}); -} - -abstract class CommonSearchPageState +abstract class CommonSearchPageState extends State { CommonSearchController get controller; diff --git a/lib/pages/download/controller.dart b/lib/pages/download/controller.dart index 6decffc11..bce178f5e 100644 --- a/lib/pages/download/controller.dart +++ b/lib/pages/download/controller.dart @@ -1,14 +1,25 @@ import 'dart:async'; +import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/models_new/download/download_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show BaseMultiSelectMixin; import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; -class DownloadPageController extends GetxController { +class DownloadPageController extends GetxController + with BaseMultiSelectMixin { final _downloadService = Get.find(); final pages = RxList(); final flag = RxInt(0); + @override + List get list => pages; + @override + RxList get state => pages; + @override void onInit() { super.onInit(); @@ -35,15 +46,6 @@ class DownloadPageController extends GetxController { final page = list.firstWhereOrNull((e) => e.pageId == pageId); if (page != null) { final aSortKey = entry.sortKey; - if (!entry.isCompleted) { - if (page.entry case final lastEntry?) { - if (aSortKey < lastEntry.sortKey) { - page.entry = entry; - } - } else { - page.entry = entry; - } - } final bSortKey = page.sortKey; if (aSortKey < bSortKey) { page @@ -61,7 +63,6 @@ class DownloadPageController extends GetxController { sortKey: entry.sortKey, seasonType: entry.ep?.seasonType, entrys: [entry], - entry: entry.isCompleted ? null : entry, ), ); } @@ -69,4 +70,32 @@ class DownloadPageController extends GetxController { pages.value = list; flag.value++; } + + @override + void onRemove() { + showConfirmDialog( + context: Get.context!, + title: '确定删除选中视频?', + onConfirm: () async { + SmartDialog.showLoading(); + final allChecked = this.allChecked.toList(); + final watchProgress = GStorage.watchProgress; + for (var page in allChecked) { + await watchProgress.deleteAll( + page.entrys.map((e) => e.cid.toString()), + ); + await _downloadService.deletePage( + pageDirPath: page.dirPath, + refresh: false, + ); + } + _downloadService.flagNotifier.refresh(); + if (enableMultiSelect.value) { + rxCount.value = 0; + enableMultiSelect.value = false; + } + SmartDialog.dismiss(); + }, + ); + } } diff --git a/lib/pages/download/detail/view.dart b/lib/pages/download/detail/view.dart index ca81b28b0..7fd5fa501 100644 --- a/lib/pages/download/detail/view.dart +++ b/lib/pages/download/detail/view.dart @@ -1,14 +1,20 @@ import 'dart:async'; +import 'package:PiliPlus/common/widgets/appbar/appbar.dart'; +import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show BaseMultiSelectMixin; import 'package:PiliPlus/pages/download/controller.dart'; import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/storage.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class DownloadDetailPage extends StatefulWidget { @@ -28,11 +34,15 @@ class DownloadDetailPage extends StatefulWidget { } class _DownloadDetailPageState extends State - with GridMixin { + with BaseMultiSelectMixin { StreamSubscription? _sub; final _downloadItems = RxList(); final _controller = Get.find(); final _downloadService = Get.find(); + @override + RxList get list => _downloadItems; + @override + RxList get state => _downloadItems; @override void initState() { @@ -71,47 +81,125 @@ class _DownloadDetailPageState extends State @override Widget build(BuildContext context) { - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar(title: Text(widget.title)), - body: CustomScrollView( - slivers: [ - ViewSliverSafeArea( - sliver: Obx(() { - if (_downloadItems.isNotEmpty) { - return SliverGrid.builder( - gridDelegate: gridDelegate, - itemBuilder: (context, index) { - final entry = _downloadItems[index]; - return DetailItem( - entry: entry, - progress: widget.progress, - downloadService: _downloadService, - showTitle: false, - onDelete: () async { - if (_downloadItems.length == 1) { - await _closeSub(); - await _downloadService.deletePage( - pageDirPath: entry.pageDirPath, - ); - if (context.mounted) { - Get.back(); - } - } else { - _downloadService.deleteDownload(entry: entry); - } - GStorage.watchProgress.delete(entry.cid.toString()); - }, - ); + return Obx(() { + final enableMultiSelect = this.enableMultiSelect.value; + return PopScope( + canPop: !enableMultiSelect, + onPopInvokedWithResult: (didPop, result) { + if (enableMultiSelect) { + handleSelect(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: MultiSelectAppBarWidget( + ctr: this, + child: AppBar( + title: Text(widget.title), + actions: [ + IconButton( + tooltip: '多选', + onPressed: () { + if (enableMultiSelect) { + handleSelect(); + } else { + this.enableMultiSelect.value = true; + } }, - itemCount: _downloadItems.length, - ); - } - return const HttpError(); - }), + icon: const Icon(Icons.edit_note), + ), + const SizedBox(width: 6), + ], + ), ), - ], - ), + body: CustomScrollView( + slivers: [ + ViewSliverSafeArea( + sliver: Obx(() { + if (_downloadItems.isNotEmpty) { + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 2, + mainAxisExtent: 100, + maxCrossAxisExtent: Grid.smallCardWidth * 2, + ), + itemBuilder: (context, index) { + final entry = _downloadItems[index]; + return DetailItem( + entry: entry, + progress: widget.progress, + downloadService: _downloadService, + showTitle: false, + onDelete: () async { + if (_downloadItems.length == 1) { + await _closeSub(); + await _downloadService.deletePage( + pageDirPath: entry.pageDirPath, + ); + if (mounted) { + Get.back(); + } + } else { + _downloadService.deleteDownload( + entry: entry, + removeList: true, + ); + } + GStorage.watchProgress.delete(entry.cid.toString()); + }, + controller: this, + ); + }, + itemCount: _downloadItems.length, + ); + } + return const HttpError(); + }), + ), + ], + ), + ), + ); + }); + } + + @override + void onRemove() { + showConfirmDialog( + context: context, + title: '确定删除选中视频?', + onConfirm: () async { + SmartDialog.showLoading(); + final watchProgress = GStorage.watchProgress; + final allChecked = this.allChecked.toSet(); + final isDeleteAll = allChecked.length == _downloadItems.length; + if (isDeleteAll) { + await _closeSub(); + } + for (var entry in allChecked) { + await watchProgress.deleteAll( + allChecked.map((e) => e.cid.toString()), + ); + await _downloadService.deleteDownload( + entry: entry, + removeList: true, + refresh: false, + ); + } + _downloadService.flagNotifier.refresh(); + if (isDeleteAll) { + SmartDialog.dismiss(); + if (mounted) { + Get.back(); + } + } else { + if (enableMultiSelect.value) { + rxCount.value = 0; + enableMultiSelect.value = false; + } + SmartDialog.dismiss(); + } + }, ); } } diff --git a/lib/pages/download/detail/widgets/item.dart b/lib/pages/download/detail/widgets/item.dart index 2263b3eeb..eb53287d9 100644 --- a/lib/pages/download/detail/widgets/item.dart +++ b/lib/pages/download/detail/widgets/item.dart @@ -5,10 +5,13 @@ import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/progress_bar/video_progress_indicator.dart'; +import 'package:PiliPlus/common/widgets/select_mask.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models/common/video/source_type.dart'; import 'package:PiliPlus/models/common/video/video_quality.dart'; import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart'; +import 'package:PiliPlus/pages/download/downloading/view.dart'; import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; @@ -26,75 +29,97 @@ class DetailItem extends StatelessWidget { const DetailItem({ super.key, required this.entry, - required this.progress, + this.progress, required this.downloadService, - required this.onDelete, + this.onDelete, required this.showTitle, + this.isCurr = false, + // + required this.controller, + this.checked, + this.onSelect, }); final BiliDownloadEntryInfo entry; - final ValueNotifier progress; + final ValueNotifier? progress; final DownloadService downloadService; - final VoidCallback onDelete; + final VoidCallback? onDelete; final bool showTitle; + final bool isCurr; + // + final MultiSelectBase controller; + final bool? checked; + final ValueChanged? onSelect; @override Widget build(BuildContext context) { final theme = Theme.of(context); final outline = theme.colorScheme.outline; final cid = entry.source?.cid ?? entry.pageData?.cid; - void onLongPress() => showDialog( - context: context, - builder: (context) { - return AlertDialog( - clipBehavior: Clip.hardEdge, - contentPadding: const EdgeInsets.symmetric(vertical: 12), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: () { - Get.back(); - showConfirmDialog( - context: context, - title: '确定删除该视频?', - onConfirm: onDelete, - ); - }, - dense: true, - title: const Text( - '删除', - style: TextStyle(fontSize: 14), + final canDel = onDelete != null; + void onLongPress() => canDel + ? showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + showConfirmDialog( + context: context, + title: '确定删除该视频?', + onConfirm: onDelete, + ); + }, + dense: true, + title: const Text( + '删除', + style: TextStyle(fontSize: 14), + ), + ), + ListTile( + onTap: () async { + Get.back(); + final res = await downloadService.downloadDanmaku( + entry: entry, + isUpdate: true, + ); + if (res) { + SmartDialog.showToast('更新成功'); + } else { + SmartDialog.showToast('更新失败'); + } + }, + dense: true, + title: const Text( + '更新弹幕', + style: TextStyle(fontSize: 14), + ), + ), + ], ), - ), - ListTile( - onTap: () async { - Get.back(); - final res = await downloadService.downloadDanmaku( - entry: entry, - isUpdate: true, - ); - if (res) { - SmartDialog.showToast('更新成功'); - } else { - SmartDialog.showToast('更新失败'); - } - }, - dense: true, - title: const Text( - '更新弹幕', - style: TextStyle(fontSize: 14), - ), - ), - ], - ), - ); - }, - ); + ); + }, + ) + : null; + return Material( type: MaterialType.transparency, child: InkWell( onTap: () async { + if (!canDel) { + Get.to(const DownloadingPage()); + return; + } + if (controller.enableMultiSelect.value) { + (onSelect ?? controller.onSelect).call(entry); + return; + } if (entry.isCompleted) { await PageUtils.toVideoPage( aid: entry.avid, @@ -111,7 +136,7 @@ class DetailItem extends StatelessWidget { Future.delayed(const Duration(milliseconds: 400), () { if (context.mounted) { // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - progress.notifyListeners(); + progress?.notifyListeners(); } }); } @@ -125,9 +150,6 @@ class DetailItem extends StatelessWidget { downloadNext: false, ); } else { - if (entry.status == DownloadStatus.wait) { - downloadService.waitDownloadQueue.remove(entry); - } downloadService.startDownload(entry); } } @@ -186,49 +208,62 @@ class DetailItem extends StatelessWidget { top: 6.0, type: PBadgeType.gray, ), - ValueListenableBuilder( - valueListenable: progress, - builder: (_, _, _) { - final progress = GStorage.watchProgress.get( - cid.toString(), - ); - if (progress != null) { - return Positioned( - left: 0, - right: 0, - bottom: 0, - child: Stack( - clipBehavior: Clip.none, - children: [ - videoProgressIndicator( - progress / entry.totalTimeMilli, - ), - PBadge( - text: progress >= entry.totalTimeMilli - 400 - ? '已看完' - : '${DurationUtils.formatDuration( - progress ~/ 1000, - )}/' - '${DurationUtils.formatDuration( - entry.totalTimeMilli ~/ 1000, - )}', - right: 6, - bottom: 7, - type: PBadgeType.gray, - ), - ], - ), + if (progress != null) + ValueListenableBuilder( + valueListenable: progress!, + builder: (_, _, _) { + final progress = GStorage.watchProgress.get( + cid.toString(), ); - } - return PBadge( - text: DurationUtils.formatDuration( - entry.totalTimeMilli ~/ 1000, - ), - right: 6.0, - bottom: 7.0, - type: PBadgeType.gray, - ); - }, + if (progress != null) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Stack( + clipBehavior: Clip.none, + children: [ + videoProgressIndicator( + progress / entry.totalTimeMilli, + ), + PBadge( + text: progress >= entry.totalTimeMilli - 400 + ? '已看完' + : '${DurationUtils.formatDuration( + progress ~/ 1000, + )}/' + '${DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + )}', + right: 6, + bottom: 7, + type: PBadgeType.gray, + ), + ], + ), + ); + } + return PBadge( + text: DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + ), + right: 6.0, + bottom: 7.0, + type: PBadgeType.gray, + ); + }, + ) + else if (entry.totalTimeMilli != 0) + PBadge( + text: DurationUtils.formatDuration( + entry.totalTimeMilli ~/ 1000, + ), + right: 6, + bottom: 7, + type: PBadgeType.gray, + ), + Positioned.fill( + child: selectMask(theme, checked ?? entry.checked ?? false), ), ], ), @@ -303,11 +338,40 @@ class DetailItem extends StatelessWidget { left: 0, right: 0, bottom: 0, - child: entry.progressWidget( - theme: theme, - downloadService: downloadService, - isPage: false, - ), + child: isCurr + ? RepaintBoundary( + child: Obx( + () { + final curDownload = + downloadService.curDownload.value; + if (curDownload != null) { + final status = curDownload.status; + final color = + status != DownloadStatus.pause + ? theme.colorScheme.primary + : theme.colorScheme.outline; + return progressWidget( + statusMsg: status!.message, + progressStr: + status == + DownloadStatus + .downloading || + status == DownloadStatus.pause + ? '${CacheManager.formatSize(curDownload.downloadedBytes)}/${CacheManager.formatSize(curDownload.totalBytes)}' + : '', + progress: curDownload.totalBytes == 0 + ? 0 + : curDownload.downloadedBytes / + curDownload.totalBytes, + color: color, + highlightColor: theme.highlightColor, + ); + } + return entryProgress(theme); + }, + ), + ) + : entryProgress(theme), ), ], ), @@ -318,4 +382,62 @@ class DetailItem extends StatelessWidget { ), ); } + + Widget entryProgress(ThemeData theme) => progressWidget( + statusMsg: entry.status?.message ?? '暂停中', + progressStr: entry.totalBytes == 0 + ? '' + : '${CacheManager.formatSize(entry.downloadedBytes)}/${CacheManager.formatSize(entry.totalBytes)}', + progress: entry.totalBytes == 0 + ? 0 + : entry.downloadedBytes / entry.totalBytes, + color: theme.colorScheme.outline, + highlightColor: theme.highlightColor, + ); + + Widget progressWidget({ + required String statusMsg, + required String progressStr, + required double progress, + required Color color, + required Color highlightColor, + }) { + return Column( + spacing: 6, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + statusMsg, + style: TextStyle( + fontSize: 12, + height: 1, + color: color, + ), + ), + Text( + progressStr, + style: TextStyle( + fontSize: 12, + height: 1, + color: color, + ), + ), + ], + ), + LinearProgressIndicator( + // ignore: deprecated_member_use + year2023: true, + minHeight: 2.5, + borderRadius: StyleString.mdRadius, + color: color, + backgroundColor: highlightColor, + value: progress, + ), + ], + ); + } } diff --git a/lib/pages/download/downloading/view.dart b/lib/pages/download/downloading/view.dart new file mode 100644 index 000000000..a7a7a0b30 --- /dev/null +++ b/lib/pages/download/downloading/view.dart @@ -0,0 +1,130 @@ +import 'package:PiliPlus/common/widgets/appbar/appbar.dart'; +import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show BaseMultiSelectMixin; +import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class DownloadingPage extends StatefulWidget { + const DownloadingPage({super.key}); + + @override + State createState() => _DownloadingPageState(); +} + +class _DownloadingPageState extends State + with BaseMultiSelectMixin { + final _downloadService = Get.find(); + late final _waitDownloadQueue = _downloadService.waitDownloadQueue; + @override + RxList get list => _waitDownloadQueue; + @override + RxList get state => _waitDownloadQueue; + + @override + Widget build(BuildContext context) { + return Obx(() { + final enableMultiSelect = this.enableMultiSelect.value; + return PopScope( + canPop: !enableMultiSelect, + onPopInvokedWithResult: (didPop, result) { + if (enableMultiSelect) { + handleSelect(); + } + }, + child: Scaffold( + appBar: MultiSelectAppBarWidget( + ctr: this, + child: AppBar( + title: const Text('正在缓存'), + actions: [ + IconButton( + tooltip: '多选', + onPressed: () { + if (enableMultiSelect) { + handleSelect(); + } else { + this.enableMultiSelect.value = true; + } + }, + icon: const Icon(Icons.edit_note), + ), + const SizedBox(width: 6), + ], + ), + ), + body: CustomScrollView( + slivers: [ + ViewSliverSafeArea( + sliver: Obx(() { + if (_waitDownloadQueue.isNotEmpty) { + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 2, + mainAxisExtent: 100, + maxCrossAxisExtent: Grid.smallCardWidth * 2, + ), + itemCount: _waitDownloadQueue.length, + itemBuilder: (context, index) { + final entry = _waitDownloadQueue[index]; + final isCurr = entry.cid == _downloadService.curCid; + return DetailItem( + entry: entry, + downloadService: _downloadService, + showTitle: true, + isCurr: isCurr, + onDelete: () => _downloadService.deleteDownload( + entry: entry, + removeQueue: true, + ), + controller: this, + ); + }, + ); + } + return const HttpError(); + }), + ), + ], + ), + ), + ); + }); + } + + @override + void onRemove() { + showConfirmDialog( + context: context, + title: '确定删除选中视频?', + onConfirm: () async { + SmartDialog.showLoading(); + final allChecked = this.allChecked.toList(); + for (var entry in allChecked) { + await _downloadService.deleteDownload( + entry: entry, + refresh: false, + downloadNext: false, + ); + } + _downloadService.waitDownloadQueue.removeWhere(allChecked.contains); + if (_downloadService.curDownload.value == null) { + _downloadService.nextDownload(); + } + if (enableMultiSelect.value) { + rxCount.value = 0; + enableMultiSelect.value = false; + } + SmartDialog.dismiss(); + }, + ); + } +} diff --git a/lib/pages/download/search/controller.dart b/lib/pages/download/search/controller.dart new file mode 100644 index 000000000..029ea445b --- /dev/null +++ b/lib/pages/download/search/controller.dart @@ -0,0 +1,79 @@ +import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/multi_select/base.dart' + show BaseMultiSelectMixin; +import 'package:PiliPlus/pages/common/search/common_search_controller.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/storage.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class DownloadSearchController + extends + CommonSearchController< + List, + BiliDownloadEntryInfo + > + with BaseMultiSelectMixin { + final _downloadService = Get.find(); + + @override + List get list => loadingState.value.data!; + @override + Rx?>> get state => loadingState; + + @override + Future>> customGetData() async { + final text = editController.text.toLowerCase(); + return Success( + _downloadService.downloadList + .where( + (e) => + e.title.toLowerCase().contains(text) || + e.showTitle.toLowerCase().contains(text), + ) + .toList(), + ); + } + + void onRemoveSingle(int index, BiliDownloadEntryInfo entry) { + loadingState + ..value.data!.removeAt(index) + ..refresh(); + _downloadService.deleteDownload( + entry: entry, + removeList: true, + ); + GStorage.watchProgress.delete(entry.cid.toString()); + } + + @override + void onRemove() { + showConfirmDialog( + context: Get.context!, + title: '确定删除选中视频?', + onConfirm: () async { + SmartDialog.showLoading(); + final allChecked = this.allChecked.toList(); + for (var entry in allChecked) { + await GStorage.watchProgress.delete(entry.cid.toString()); + await _downloadService.deleteDownload( + entry: entry, + removeList: true, + refresh: false, + ); + } + loadingState + ..value.data!.removeWhere(allChecked.contains) + ..refresh(); + _downloadService.flagNotifier.refresh(); + if (enableMultiSelect.value) { + rxCount.value = 0; + enableMultiSelect.value = false; + } + SmartDialog.dismiss(); + }, + ); + } +} diff --git a/lib/pages/download/search/view.dart b/lib/pages/download/search/view.dart new file mode 100644 index 000000000..18bb64daf --- /dev/null +++ b/lib/pages/download/search/view.dart @@ -0,0 +1,75 @@ +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/models_new/download/bili_download_entry_info.dart'; +import 'package:PiliPlus/pages/common/search/common_search_page.dart'; +import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; +import 'package:PiliPlus/pages/download/search/controller.dart'; +import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; +import 'package:get/get.dart'; + +class DownloadSearchPage extends StatefulWidget { + const DownloadSearchPage({ + super.key, + required this.progress, + }); + + final ValueNotifier progress; + + @override + State createState() => _DownloadSearchPageState(); +} + +class _DownloadSearchPageState + extends + CommonSearchPageState< + DownloadSearchPage, + List, + BiliDownloadEntryInfo + > { + @override + DownloadSearchController controller = Get.put(DownloadSearchController()); + final _downloadService = Get.find(); + + @override + List? get extraActions => [ + IconButton( + tooltip: '多选', + onPressed: () { + if (controller.enableMultiSelect.value) { + controller.handleSelect(); + } else { + controller.enableMultiSelect.value = true; + } + }, + icon: const Icon(Icons.edit_note), + ), + ]; + + @override + Widget buildList(List list) { + if (list.isNotEmpty) { + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 2, + mainAxisExtent: 100, + maxCrossAxisExtent: Grid.smallCardWidth * 2, + ), + itemBuilder: (context, index) { + final entry = list[index]; + return DetailItem( + entry: entry, + progress: widget.progress, + downloadService: _downloadService, + showTitle: true, + onDelete: () => controller.onRemoveSingle(index, entry), + controller: controller, + ); + }, + itemCount: list.length, + ); + } + return const HttpError(); + } +} diff --git a/lib/pages/download/view.dart b/lib/pages/download/view.dart index 6e771139f..0e0377b4d 100644 --- a/lib/pages/download/view.dart +++ b/lib/pages/download/view.dart @@ -1,21 +1,24 @@ import 'dart:async'; import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/appbar/appbar.dart'; import 'package:PiliPlus/common/widgets/badge.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; -import 'package:PiliPlus/common/widgets/view_sliver_safe_area.dart'; +import 'package:PiliPlus/common/widgets/select_mask.dart'; import 'package:PiliPlus/models/common/badge_type.dart'; import 'package:PiliPlus/models_new/download/download_info.dart'; import 'package:PiliPlus/pages/download/controller.dart'; import 'package:PiliPlus/pages/download/detail/view.dart'; import 'package:PiliPlus/pages/download/detail/widgets/item.dart'; +import 'package:PiliPlus/pages/download/search/view.dart'; import 'package:PiliPlus/services/download/download_service.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/storage.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -26,7 +29,7 @@ class DownloadPage extends StatefulWidget { State createState() => _DownloadPageState(); } -class _DownloadPageState extends State with GridMixin { +class _DownloadPageState extends State { final _downloadService = Get.find(); final _controller = Get.put(DownloadPageController()); final _progress = ValueNotifier(null); @@ -40,123 +43,232 @@ class _DownloadPageState extends State with GridMixin { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar(title: const Text('离线缓存')), - body: CustomScrollView( - slivers: [ - ViewSliverSafeArea( - sliver: Obx(() { - if (_controller.pages.isNotEmpty) { - return SliverGrid.builder( - gridDelegate: gridDelegate, - itemBuilder: (context, index) { - final item = _controller.pages[index]; - if (item.entrys.length == 1) { - final entry = item.entrys.first; - return DetailItem( - entry: entry, - progress: _progress, - downloadService: _downloadService, - showTitle: true, - onDelete: () { - _downloadService.deleteDownload(entry: entry); - GStorage.watchProgress.delete(entry.cid.toString()); - }, - ); - } - return _buildItem(theme, item); + final padding = MediaQuery.viewPaddingOf(context); + return Obx(() { + final enableMultiSelect = _controller.enableMultiSelect.value; + return PopScope( + canPop: !enableMultiSelect, + onPopInvokedWithResult: (didPop, result) { + if (enableMultiSelect) { + _controller.handleSelect(); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + appBar: MultiSelectAppBarWidget( + ctr: _controller, + child: AppBar( + title: const Text('离线缓存'), + actions: [ + IconButton( + tooltip: '搜索', + onPressed: () async { + await _downloadService.waitForInitialization; + if (!mounted) return; + Get.to(DownloadSearchPage(progress: _progress)); }, - itemCount: _controller.pages.length, - ); - } - return const HttpError(); - }), + icon: const Icon(Icons.search), + ), + IconButton( + tooltip: '多选', + onPressed: () { + if (enableMultiSelect) { + _controller.handleSelect(); + } else { + _controller.enableMultiSelect.value = true; + } + }, + icon: const Icon(Icons.edit_note), + ), + const SizedBox(width: 6), + ], + ), ), - ], - ), - ); + body: Padding( + padding: EdgeInsets.only(left: padding.left, right: padding.right), + child: CustomScrollView( + slivers: [ + Obx(() { + final entry = + _downloadService.waitDownloadQueue.firstWhereOrNull( + (e) => e.cid == _downloadService.curCid, + ) ?? + _downloadService.waitDownloadQueue.firstOrNull; + if (entry != null) { + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(left: 12, bottom: 7), + sliver: SliverToBoxAdapter( + child: Text( + '正在缓存 (${_downloadService.waitDownloadQueue.length})', + ), + ), + ), + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: DetailItem( + entry: entry, + progress: _progress, + downloadService: _downloadService, + showTitle: true, + isCurr: true, + controller: _controller, + ), + ), + ), + ], + ); + } + return const SliverToBoxAdapter(); + }), + Obx(() { + if (_controller.pages.isNotEmpty) { + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + left: 12, + bottom: 7, + top: _downloadService.waitDownloadQueue.isEmpty + ? 0 + : 7, + ), + sliver: const SliverToBoxAdapter( + child: Text('已缓存视频'), + ), + ), + SliverGrid.builder( + gridDelegate: + SliverGridDelegateWithMaxCrossAxisExtent( + mainAxisSpacing: 2, + mainAxisExtent: 100, + maxCrossAxisExtent: Grid.smallCardWidth * 2, + ), + itemBuilder: (context, index) { + final item = _controller.pages[index]; + if (item.entrys.length == 1) { + final entry = item.entrys.first; + return DetailItem( + entry: entry, + progress: _progress, + downloadService: _downloadService, + showTitle: true, + onDelete: () { + _downloadService.deleteDownload( + entry: entry, + removeList: true, + ); + GStorage.watchProgress.delete( + entry.cid.toString(), + ); + }, + checked: item.checked ?? false, + onSelect: (_) => _controller.onSelect(item), + controller: _controller, + ); + } + return _buildItem(theme, item); + }, + itemCount: _controller.pages.length, + ), + ], + ); + } + if (_downloadService.waitDownloadQueue.isNotEmpty) { + return const SliverToBoxAdapter(); + } + return const HttpError(); + }), + SliverToBoxAdapter( + child: SizedBox(height: padding.bottom + 100), + ), + ], + ), + ), + ), + ); + }); } Widget _buildItem(ThemeData theme, DownloadPageInfo pageInfo) { - final outline = theme.colorScheme.outline; - final entry = pageInfo.entry; - final isCompleted = entry == null; - void onLongPress() => isCompleted - ? showDialog( - context: context, - builder: (context) { - return AlertDialog( - clipBehavior: Clip.hardEdge, - contentPadding: const EdgeInsets.symmetric(vertical: 12), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: () { - Get.back(); - showConfirmDialog( - context: context, - title: '确定删除?', - onConfirm: () async { - final watchProgress = GStorage.watchProgress; - await Future.wait( - pageInfo.entrys.map((e) { - final cid = e.pageData?.cid ?? e.source?.cid; - return watchProgress.delete(cid.toString()); - }), - ); - _downloadService.deletePage( - pageDirPath: pageInfo.dirPath, - ); - }, - ); - }, - dense: true, - title: const Text( - '删除', - style: TextStyle(fontSize: 14), - ), - ), - ListTile( - onTap: () async { - Get.back(); - final res = await Future.wait( - pageInfo.entrys.map( - (e) => _downloadService.downloadDanmaku( - entry: e, - isUpdate: true, - ), - ), - ); - if (res.every((e) => e)) { - SmartDialog.showToast('更新成功'); - } else { - SmartDialog.showToast('更新失败'); - } - }, - dense: true, - title: const Text( - '更新弹幕', - style: TextStyle(fontSize: 14), - ), - ), - ], + void onLongPress() => showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + showConfirmDialog( + context: context, + title: '确定删除?', + onConfirm: () async { + await GStorage.watchProgress.deleteAll( + pageInfo.entrys.map((e) => e.cid.toString()), + ); + _downloadService.deletePage( + pageDirPath: pageInfo.dirPath, + ); + }, + ); + }, + dense: true, + title: const Text( + '删除', + style: TextStyle(fontSize: 14), ), - ); - }, - ) - : null; + ), + ListTile( + onTap: () async { + Get.back(); + final res = await Future.wait( + pageInfo.entrys.map( + (e) => _downloadService.downloadDanmaku( + entry: e, + isUpdate: true, + ), + ), + ); + if (res.every((e) => e)) { + SmartDialog.showToast('更新成功'); + } else { + SmartDialog.showToast('更新失败'); + } + }, + dense: true, + title: const Text( + '更新弹幕', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }, + ); + final first = pageInfo.entrys.first; return Material( type: MaterialType.transparency, child: InkWell( - onTap: () => Get.to( - DownloadDetailPage( - pageId: pageInfo.pageId, - title: pageInfo.title, - progress: _progress, - ), - ), + onTap: () { + if (_controller.enableMultiSelect.value) { + _controller.onSelect(pageInfo); + return; + } + Get.to( + DownloadDetailPage( + pageId: pageInfo.pageId, + title: pageInfo.title, + progress: _progress, + ), + ); + }, onLongPress: onLongPress, onSecondaryTap: Utils.isMobile ? null : onLongPress, child: Padding( @@ -202,6 +314,9 @@ class _DownloadPageState extends State with GridMixin { right: 6.0, top: 6.0, ), + Positioned.fill( + child: selectMask(theme, pageInfo.checked ?? false), + ), ], ), Expanded( @@ -222,22 +337,24 @@ class _DownloadPageState extends State with GridMixin { overflow: TextOverflow.ellipsis, ), ), - if (isCompleted) - Text( - '已完成', - maxLines: 1, - style: TextStyle( - fontSize: 12, - height: 1, - color: outline, - ), - ) - else - entry.progressWidget( - theme: theme, - downloadService: _downloadService, - isPage: true, - ), + Row( + crossAxisAlignment: .end, + mainAxisAlignment: .spaceBetween, + children: [ + if (first.ownerName case final ownerName?) + Text( + ownerName, + style: TextStyle( + fontSize: 12, + height: 1.6, + color: theme.colorScheme.outline, + ), + ) + else + const Spacer(), + pageInfo.entrys.first.moreBtn(theme), + ], + ), ], ), ), diff --git a/lib/pages/fav/topic/view.dart b/lib/pages/fav/topic/view.dart index adeeb2973..fd5b02da3 100644 --- a/lib/pages/fav/topic/view.dart +++ b/lib/pages/fav/topic/view.dart @@ -7,7 +7,8 @@ import 'package:PiliPlus/models_new/fav/fav_topic/topic_item.dart'; import 'package:PiliPlus/pages/fav/topic/controller.dart'; import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:get/get.dart'; class FavTopicPage extends StatefulWidget { diff --git a/lib/pages/fav_search/view.dart b/lib/pages/fav_search/view.dart index 09b86fc81..7317afa5d 100644 --- a/lib/pages/fav_search/view.dart +++ b/lib/pages/fav_search/view.dart @@ -9,7 +9,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class FavSearchPage extends CommonSearchPage { +class FavSearchPage extends StatefulWidget { const FavSearchPage({super.key}); @override diff --git a/lib/pages/follow_search/view.dart b/lib/pages/follow_search/view.dart index 5f1644720..aed6e6223 100644 --- a/lib/pages/follow_search/view.dart +++ b/lib/pages/follow_search/view.dart @@ -7,7 +7,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class FollowSearchPage extends CommonSearchPage { +class FollowSearchPage extends StatefulWidget { const FollowSearchPage({ super.key, this.mid, diff --git a/lib/pages/follow_type/view.dart b/lib/pages/follow_type/view.dart index 6508209e2..b8f16d851 100644 --- a/lib/pages/follow_type/view.dart +++ b/lib/pages/follow_type/view.dart @@ -7,7 +7,8 @@ import 'package:PiliPlus/models_new/follow/list.dart'; import 'package:PiliPlus/pages/follow/widgets/follow_item.dart'; import 'package:PiliPlus/pages/follow_type/controller.dart'; import 'package:PiliPlus/utils/grid.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:get/get.dart'; abstract class FollowTypePageState extends State { diff --git a/lib/pages/history_search/view.dart b/lib/pages/history_search/view.dart index e7b4a78fd..1a50284e8 100644 --- a/lib/pages/history_search/view.dart +++ b/lib/pages/history_search/view.dart @@ -8,7 +8,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class HistorySearchPage extends CommonSearchPage { +class HistorySearchPage extends StatefulWidget { const HistorySearchPage({super.key}); @override diff --git a/lib/pages/later_search/view.dart b/lib/pages/later_search/view.dart index 772e27721..d7069ceed 100644 --- a/lib/pages/later_search/view.dart +++ b/lib/pages/later_search/view.dart @@ -10,7 +10,7 @@ import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -class LaterSearchPage extends CommonSearchPage { +class LaterSearchPage extends StatefulWidget { const LaterSearchPage({super.key}); @override diff --git a/lib/pages/live_search/child/view.dart b/lib/pages/live_search/child/view.dart index 366b02c35..26c9e3ca6 100644 --- a/lib/pages/live_search/child/view.dart +++ b/lib/pages/live_search/child/view.dart @@ -9,7 +9,8 @@ import 'package:PiliPlus/pages/live_search/child/controller.dart'; import 'package:PiliPlus/pages/live_search/widgets/live_search_room.dart'; import 'package:PiliPlus/pages/live_search/widgets/live_search_user.dart'; import 'package:PiliPlus/utils/grid.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:get/get.dart'; class LiveSearchChildPage extends StatefulWidget { diff --git a/lib/pages/search_panel/pgc/view.dart b/lib/pages/search_panel/pgc/view.dart index c0d5b16d9..ee12cd7b6 100644 --- a/lib/pages/search_panel/pgc/view.dart +++ b/lib/pages/search_panel/pgc/view.dart @@ -5,7 +5,8 @@ import 'package:PiliPlus/pages/search_panel/controller.dart'; import 'package:PiliPlus/pages/search_panel/pgc/widgets/item.dart'; import 'package:PiliPlus/pages/search_panel/view.dart'; import 'package:PiliPlus/utils/grid.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:get/get.dart'; class SearchPgcPanel extends CommonSearchPanel { diff --git a/lib/pages/search_panel/user/view.dart b/lib/pages/search_panel/user/view.dart index e1a6da360..dd207c753 100644 --- a/lib/pages/search_panel/user/view.dart +++ b/lib/pages/search_panel/user/view.dart @@ -5,7 +5,8 @@ import 'package:PiliPlus/pages/search_panel/user/controller.dart'; import 'package:PiliPlus/pages/search_panel/user/widgets/item.dart'; import 'package:PiliPlus/pages/search_panel/view.dart'; import 'package:PiliPlus/utils/grid.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' + hide SliverGridDelegateWithMaxCrossAxisExtent; import 'package:get/get.dart'; class SearchUserPanel extends CommonSearchPanel { diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index 8211ce472..54e69615b 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -1885,9 +1885,10 @@ class VideoDetailController extends GetxController if (!context.mounted) { return; } - final Set cidSet = downloadService.downloadList - .map((e) => e.cid) - .toSet(); + final Set cidSet = + (downloadService.downloadList + downloadService.waitDownloadQueue) + .map((e) => e.cid) + .toSet(); final index = episodes!.indexWhere( (e) => e.cid == (seasonCid ?? cid.value), ); diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart index 36afc04e4..975aa3041 100644 --- a/lib/services/download/download_service.dart +++ b/lib/services/download/download_service.dart @@ -34,9 +34,11 @@ class DownloadService extends GetxService { final _lock = Lock(); final flagNotifier = {}; - final waitDownloadQueue = []; - final downloadList = RxList(); + final waitDownloadQueue = RxList(); + final downloadList = []; + int? _curCid; + int? get curCid => _curCid; final curDownload = Rxn(); void _updateCurStatus(DownloadStatus status) { if (curDownload.value != null) { @@ -62,15 +64,14 @@ class DownloadService extends GetxService { } Future _readDownloadList() async { + downloadList.clear(); final downloadDir = Directory(await _getDownloadPath()); - final list = []; await for (final dir in downloadDir.list()) { if (dir is Directory) { - list.addAll(await _readDownloadDirectory(dir)); + downloadList.addAll(await _readDownloadDirectory(dir)); } } - downloadList.value = list - ..sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp)); + downloadList.sort((a, b) => b.timeUpdateStamp.compareTo(a.timeUpdateStamp)); } @pragma('vm:notify-debugger-on-exception') @@ -92,8 +93,9 @@ class DownloadService extends GetxService { final entry = BiliDownloadEntryInfo.fromJson(jsonDecode(entryJson)) ..pageDirPath = pageDir.path ..entryDirPath = entryDir.path; - result.add(entry); - if (!entry.isCompleted) { + if (entry.isCompleted) { + result.add(entry); + } else { waitDownloadQueue.add(entry..status = DownloadStatus.wait); } } catch (_) {} @@ -114,6 +116,9 @@ class DownloadService extends GetxService { if (downloadList.indexWhere((e) => e.cid == cid) != -1) { return; } + if (waitDownloadQueue.indexWhere((e) => e.cid == cid) != -1) { + return; + } final pageData = PageInfo( cid: cid, page: page.page!, @@ -171,6 +176,9 @@ class DownloadService extends GetxService { if (downloadList.indexWhere((e) => e.cid == cid) != -1) { return; } + if (waitDownloadQueue.indexWhere((e) => e.cid == cid) != -1) { + return; + } final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; final source = SourceInfo( avId: episode.aid!, @@ -235,13 +243,10 @@ class DownloadService extends GetxService { ..pageDirPath = entryDir.parent.path ..entryDirPath = entryDir.path ..status = DownloadStatus.wait; - downloadList.insert(0, entry); - flagNotifier.refresh(); + waitDownloadQueue.add(entry); final currStatus = curDownload.value?.status?.index; if (currStatus == null || currStatus > 3) { startDownload(entry); - } else { - waitDownloadQueue.add(entry); } } @@ -283,7 +288,9 @@ class DownloadService extends GetxService { curDownload.value?.status = DownloadStatus.pause; } + _curCid = entry.cid; curDownload.value = entry; + waitDownloadQueue.refresh(); await _startDownload(entry); }); } @@ -450,6 +457,11 @@ class DownloadService extends GetxService { } void _onDone([Object? error]) { + if (error != null) { + _updateCurStatus(DownloadStatus.failDownload); + return; + } + final status = switch (_audioDownloadManager?.status) { DownloadStatus.downloading => DownloadStatus.audioDownloading, DownloadStatus.failDownload => DownloadStatus.failDownloadAudio, @@ -458,9 +470,7 @@ class DownloadService extends GetxService { _updateCurStatus(status); if (curDownload.value case final curEntryInfo?) { - if (error == null) { - curEntryInfo.downloadedBytes = curEntryInfo.totalBytes; - } + curEntryInfo.downloadedBytes = curEntryInfo.totalBytes; if (status == DownloadStatus.completed) { _completeDownload(); } else { @@ -493,29 +503,40 @@ class DownloadService extends GetxService { ..downloadedBytes = entry.totalBytes ..isCompleted = true; await _updateBiliDownloadEntryJson(entry); + waitDownloadQueue.remove(entry); + downloadList.insert(0, entry); flagNotifier.refresh(); + _curCid = null; curDownload.value = null; _downloadManager = null; _audioDownloadManager = null; - _nextDownload(); + nextDownload(); } - void _nextDownload() { + void nextDownload() { if (waitDownloadQueue.isNotEmpty) { - final next = waitDownloadQueue.removeAt(0); - if (!next.isCompleted && downloadList.contains(next)) { - startDownload(next); - } else { - _nextDownload(); - } + startDownload(waitDownloadQueue.first); } } Future deleteDownload({ required BiliDownloadEntryInfo entry, + bool removeList = false, + bool removeQueue = false, + bool refresh = true, + bool downloadNext = true, }) async { + if (removeList) { + downloadList.remove(entry); + } + if (removeQueue) { + waitDownloadQueue.remove(entry); + } if (curDownload.value?.cid == entry.cid) { - await cancelDownload(isDelete: true); + await cancelDownload( + isDelete: true, + downloadNext: downloadNext, + ); } final downloadDir = Directory(entry.pageDirPath); if (downloadDir.existsSync()) { @@ -528,15 +549,20 @@ class DownloadService extends GetxService { } } } - downloadList.remove(entry); - waitDownloadQueue.remove(entry); - flagNotifier.refresh(); + if (refresh) { + flagNotifier.refresh(); + } } - Future deletePage({required String pageDirPath}) async { + Future deletePage({ + required String pageDirPath, + bool refresh = true, + }) async { await Directory(pageDirPath).tryDel(recursive: true); downloadList.removeWhere((e) => e.pageDirPath == pageDirPath); - flagNotifier.refresh(); + if (refresh) { + flagNotifier.refresh(); + } } Future cancelDownload({ @@ -554,17 +580,18 @@ class DownloadService extends GetxService { } } if (isDelete) { + _curCid = null; curDownload.value = null; } else { _updateCurStatus(DownloadStatus.pause); } if (downloadNext) { - _nextDownload(); + nextDownload(); } } } -extension on Set { +extension SetExt on Set { void refresh() { for (var i in this) { i(); diff --git a/lib/utils/grid.dart b/lib/utils/grid.dart index 29f083680..2764866f7 100644 --- a/lib/utils/grid.dart +++ b/lib/utils/grid.dart @@ -137,3 +137,106 @@ class SliverGridDelegateWithExtentAndRatio extends SliverGridDelegate { return flag; } } + +class SliverGridDelegateWithMaxCrossAxisExtent extends SliverGridDelegate { + /// Creates a delegate that makes grid layouts with tiles that have a maximum + /// cross-axis extent. + /// + /// The [maxCrossAxisExtent], [mainAxisExtent], [mainAxisSpacing], + /// and [crossAxisSpacing] arguments must not be negative. + /// The [childAspectRatio] argument must be greater than zero. + SliverGridDelegateWithMaxCrossAxisExtent({ + required this.maxCrossAxisExtent, + this.mainAxisSpacing = 0.0, + this.crossAxisSpacing = 0.0, + this.childAspectRatio = 1.0, + this.mainAxisExtent, + }) : assert(maxCrossAxisExtent > 0), + assert(mainAxisSpacing >= 0), + assert(crossAxisSpacing >= 0), + assert(childAspectRatio > 0), + assert(mainAxisExtent == null || mainAxisExtent >= 0); + + /// The maximum extent of tiles in the cross axis. + /// + /// This delegate will select a cross-axis extent for the tiles that is as + /// large as possible subject to the following conditions: + /// + /// - The extent evenly divides the cross-axis extent of the grid. + /// - The extent is at most [maxCrossAxisExtent]. + /// + /// For example, if the grid is vertical, the grid is 500.0 pixels wide, and + /// [maxCrossAxisExtent] is 150.0, this delegate will create a grid with 4 + /// columns that are 125.0 pixels wide. + final double maxCrossAxisExtent; + + /// The number of logical pixels between each child along the main axis. + final double mainAxisSpacing; + + /// The number of logical pixels between each child along the cross axis. + final double crossAxisSpacing; + + /// The ratio of the cross-axis to the main-axis extent of each child. + final double childAspectRatio; + + /// The extent of each tile in the main axis. If provided it would define the + /// logical pixels taken by each tile in the main-axis. + /// + /// If null, [childAspectRatio] is used instead. + final double? mainAxisExtent; + + bool _debugAssertIsValid(double crossAxisExtent) { + assert(crossAxisExtent > 0.0); + assert(maxCrossAxisExtent > 0.0); + assert(mainAxisSpacing >= 0.0); + assert(crossAxisSpacing >= 0.0); + assert(childAspectRatio > 0.0); + return true; + } + + SliverGridLayout? layoutCache; + double? crossAxisExtentCache; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + assert(_debugAssertIsValid(constraints.crossAxisExtent)); + if (layoutCache != null && + constraints.crossAxisExtent == crossAxisExtentCache) { + return layoutCache!; + } + crossAxisExtentCache = constraints.crossAxisExtent; + int crossAxisCount = + (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) + .ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = max(1, crossAxisCount); + final double usableCrossAxisExtent = max( + 0.0, + constraints.crossAxisExtent - crossAxisSpacing * (crossAxisCount - 1), + ); + final double childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + final double childMainAxisExtent = + mainAxisExtent ?? childCrossAxisExtent / childAspectRatio; + return layoutCache = SliverGridRegularTileLayout( + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(SliverGridDelegateWithMaxCrossAxisExtent oldDelegate) { + final flag = + oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || + oldDelegate.mainAxisSpacing != mainAxisSpacing || + oldDelegate.crossAxisSpacing != crossAxisSpacing || + oldDelegate.childAspectRatio != childAspectRatio || + oldDelegate.mainAxisExtent != mainAxisExtent; + if (flag) layoutCache = null; + return flag; + } +}