diff --git a/lib/appdata.dart b/lib/appdata.dart index c73618d..76d480a 100644 --- a/lib/appdata.dart +++ b/lib/appdata.dart @@ -18,6 +18,7 @@ class _Appdata { "downloadSubPath": r"/${id}-p${index}.${ext}", "tagsWeight": "", "useTranslatedNameForDownload": false, + "maxDownloadParallelism": 3 }; bool lock = false; diff --git a/lib/components/batch_download.dart b/lib/components/batch_download.dart new file mode 100644 index 0000000..04aa997 --- /dev/null +++ b/lib/components/batch_download.dart @@ -0,0 +1,144 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:pixes/components/md.dart'; +import 'package:pixes/components/message.dart'; +import 'package:pixes/foundation/app.dart'; +import 'package:pixes/network/download.dart'; +import 'package:pixes/utils/translation.dart'; + +import '../network/network.dart'; + +class BatchDownloadButton extends StatelessWidget { + const BatchDownloadButton({super.key, required this.request}); + + final Future>> Function() request; + + @override + Widget build(BuildContext context) { + return Button( + child: const Icon(MdIcons.download, size: 20,), + onPressed: () { + showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.transparent, + useRootNavigator: false, + builder: (context) => _DownloadDialog(request)); + }, + ); + } +} + +class _DownloadDialog extends StatefulWidget { + const _DownloadDialog(this.request); + + final Future>> Function() request; + + @override + State<_DownloadDialog> createState() => _DownloadDialogState(); +} + +class _DownloadDialogState extends State<_DownloadDialog> { + int maxCount = 30; + + bool loading = false; + + bool cancel = false; + + @override + Widget build(BuildContext context) { + return ContentDialog( + title: Text("Batch download".tl), + content: SizedBox( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${"Maximum number of downloads".tl}:'), + const SizedBox(height: 16,), + SizedBox( + height: 42, + width: 196, + child: NumberBox( + value: maxCount, + onChanged: (value) { + if(!loading) { + setState(() => maxCount = value ?? maxCount); + } + }, + allowExpressions: true, + mode: SpinButtonPlacementMode.inline, + smallChange: 10, + largeChange: 30, + clearButton: false, + ), + ) + ], + ).paddingVertical(8), + ), + actions: [ + Button(child: Text("Cancel".tl), onPressed: () { + cancel = true; + context.pop(); + }), + if(!loading) + FilledButton(onPressed: load, child: Text("Continue".tl)) + else + FilledButton(onPressed: (){}, child: const SizedBox( + height: 20, + width: 64, + child: Center( + child: SizedBox.square( + dimension: 18, + child: ProgressRing( + strokeWidth: 1.6, + ), + ), + ), + )) + ], + ); + } + + void load() async{ + setState(() { + loading = true; + }); + + var request = widget.request(); + + List all = []; + String? nextUrl; + int retryCount = 0; + while(nextUrl != "end" && all.length < maxCount) { + if(nextUrl != null) { + request = Network().getIllustsWithNextUrl(nextUrl); + } + var res = await request; + if(cancel || !mounted) { + return; + } + if(res.error) { + retryCount++; + if(retryCount > 3) { + setState(() { + loading = false; + }); + showToast(context, message: "Error".tl); + return; + } + await Future.delayed(Duration(seconds: 1 << retryCount)); + continue; + } + all.addAll(res.data); + nextUrl = res.subData ?? "end"; + } + int i = 0; + for(var illust in all) { + if(i > maxCount) return; + DownloadManager().addDownloadingTask(illust); + i++; + } + context.pop(); + } +} + diff --git a/lib/components/segmented_button.dart b/lib/components/segmented_button.dart index 4a8deba..e45817b 100644 --- a/lib/components/segmented_button.dart +++ b/lib/components/segmented_button.dart @@ -23,7 +23,7 @@ class SegmentedButton extends StatelessWidget { child: Card( padding: EdgeInsets.zero, child: SizedBox( - height: 36, + height: 28, child: Row( mainAxisSize: MainAxisSize.min, children: options.map((e) => buildButton(e)).toList(), diff --git a/lib/network/download.dart b/lib/network/download.dart index c00f8af..d888baa 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -101,16 +101,29 @@ class DownloadingTask { } imagePaths.add(path); _downloadingIndex++; + _retryCount = 0; } onCompleted?.call(this); } catch(e, s) { - error = e.toString(); - _stop = true; + _handleError(e); Log.error("Download", "Download error: $e\n$s"); } } + int _retryCount = 0; + + void _handleError(Object error) async{ + _retryCount++; + if(_retryCount > 3) { + _stop = true; + error = error.toString(); + return; + } + await Future.delayed(Duration(seconds: 1 << _retryCount)); + _download(); + } + static String _generateFilePath(Illust illust, int index, String ext) { final String downloadPath = appdata.settings["downloadPath"]; String subPathPatten = appdata.settings["downloadSubPath"]; @@ -297,4 +310,32 @@ class DownloadManager { ''', [illustId]); return res.map((e) => e["path"] as String).toList(); } + + Future batchDownload(Future>> request, int maxCount) async{ + List all = []; + String? nextUrl; + int retryCount = 0; + while(nextUrl != "end" && all.length < maxCount) { + if(nextUrl != null) { + request = Network().getIllustsWithNextUrl(nextUrl); + } + var res = await request; + if(res.error) { + retryCount++; + if(retryCount > 3) { + throw res.error; + } + await Future.delayed(Duration(seconds: 1 << retryCount)); + continue; + } + all.addAll(res.data); + nextUrl = res.subData ?? "end"; + } + int i = 0; + for(var illust in all) { + if(i > maxCount) return; + addDownloadingTask(illust); + i++; + } + } } \ No newline at end of file diff --git a/lib/pages/bookmarks.dart b/lib/pages/bookmarks.dart index ccf1efa..aea1b3a 100644 --- a/lib/pages/bookmarks.dart +++ b/lib/pages/bookmarks.dart @@ -1,5 +1,6 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:pixes/components/batch_download.dart'; import 'package:pixes/components/segmented_button.dart'; import 'package:pixes/components/title_bar.dart'; import 'package:pixes/foundation/app.dart'; @@ -34,19 +35,25 @@ class _BookMarkedArtworkPageState extends State{ Widget buildTab() { return TitleBar( title: "Bookmarks".tl, - action: SegmentedButton( - options: [ - SegmentedButtonOption("public", "Public".tl), - SegmentedButtonOption("private", "Private".tl), + action: Row( + children: [ + BatchDownloadButton(request: () => Network().getBookmarkedIllusts(restrict)), + const SizedBox(width: 8,), + SegmentedButton( + options: [ + SegmentedButtonOption("public", "Public".tl), + SegmentedButtonOption("private", "Private".tl), + ], + onPressed: (key) { + if(key != restrict) { + setState(() { + restrict = key; + }); + } + }, + value: restrict, + ) ], - onPressed: (key) { - if(key != restrict) { - setState(() { - restrict = key; - }); - } - }, - value: restrict, ), ); } diff --git a/lib/pages/downloading_page.dart b/lib/pages/downloading_page.dart index 74e9274..3685c39 100644 --- a/lib/pages/downloading_page.dart +++ b/lib/pages/downloading_page.dart @@ -66,6 +66,7 @@ class _DownloadingPageState extends State { controller[task.illust.id.toString()] ??= FlyoutController(); return Card( + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(vertical: 8), child: SizedBox( height: 96, diff --git a/lib/pages/following_artworks.dart b/lib/pages/following_artworks.dart index c949c19..0e8a84f 100644 --- a/lib/pages/following_artworks.dart +++ b/lib/pages/following_artworks.dart @@ -4,6 +4,7 @@ import 'package:pixes/components/title_bar.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/utils/translation.dart'; +import '../components/batch_download.dart'; import '../components/illust_widget.dart'; import '../components/loading.dart'; import '../components/segmented_button.dart'; @@ -34,20 +35,26 @@ class _FollowingArtworksPageState extends State { Widget buildTab() { return TitleBar( title: "Following".tl, - action: SegmentedButton( - options: [ - SegmentedButtonOption("all", "All".tl), - SegmentedButtonOption("public", "Public".tl), - SegmentedButtonOption("private", "Private".tl), + action: Row( + children: [ + BatchDownloadButton(request: () => Network().getFollowingArtworks(restrict)), + const SizedBox(width: 8,), + SegmentedButton( + options: [ + SegmentedButtonOption("all", "All".tl), + SegmentedButtonOption("public", "Public".tl), + SegmentedButtonOption("private", "Private".tl), + ], + onPressed: (key) { + if(key != restrict) { + setState(() { + restrict = key; + }); + } + }, + value: restrict, + ) ], - onPressed: (key) { - if(key != restrict) { - setState(() { - restrict = key; - }); - } - }, - value: restrict, ), ); } diff --git a/lib/pages/ranking.dart b/lib/pages/ranking.dart index 99a31aa..1574b5c 100644 --- a/lib/pages/ranking.dart +++ b/lib/pages/ranking.dart @@ -3,8 +3,10 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/utils/translation.dart'; +import '../components/batch_download.dart'; import '../components/illust_widget.dart'; import '../components/loading.dart'; +import '../components/title_bar.dart'; import '../network/network.dart'; class RankingPage extends StatefulWidget { @@ -48,12 +50,12 @@ class _RankingPageState extends State { } Widget buildHeader() { - return SizedBox( - child: Row( + return TitleBar( + title: "Ranking".tl, + action: Row( children: [ - Text("Ranking".tl, style: const TextStyle( - fontSize: 20, fontWeight: FontWeight.bold),), - const Spacer(), + BatchDownloadButton(request: () => Network().getRanking(type)), + const SizedBox(width: 8,), DropDownButton( title: Text(types[type]!), items: types.entries.map((e) => MenuFlyoutItem( @@ -66,8 +68,8 @@ class _RankingPageState extends State { )).toList(), ) ], - ) - ).padding(const EdgeInsets.symmetric(vertical: 8, horizontal: 16)); + ), + ); } } diff --git a/lib/pages/user_info_page.dart b/lib/pages/user_info_page.dart index 272f85f..242fe4a 100644 --- a/lib/pages/user_info_page.dart +++ b/lib/pages/user_info_page.dart @@ -2,6 +2,7 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:pixes/appdata.dart'; +import 'package:pixes/components/batch_download.dart'; import 'package:pixes/components/loading.dart'; import 'package:pixes/components/md.dart'; import 'package:pixes/foundation/app.dart'; @@ -32,7 +33,12 @@ class _UserInfoPageState extends LoadingState { slivers: [ buildUser(), buildInformation(), - SliverToBoxAdapter(child: buildHeader("Artworks"),), + SliverToBoxAdapter( + child: buildHeader( + "Artworks", + action: BatchDownloadButton( + request: () => Network().getUserIllusts(widget.id)) + ),), _UserArtworks(data.id.toString(), key: ValueKey(data.id),), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), ], @@ -146,13 +152,21 @@ class _UserInfoPageState extends LoadingState { ); } - Widget buildHeader(String title) { + Widget buildHeader(String title, {Widget? action}) { return SizedBox( width: double.infinity, - child: Text( - title, - style: const TextStyle(fontWeight: FontWeight.w600), - ).toAlign(Alignment.centerLeft)).paddingLeft(16).paddingVertical(4); + height: 38, + child: Row( + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.w600), + ).toAlign(Alignment.centerLeft), + const Spacer(), + if(action != null) + action.toAlign(Alignment.centerRight) + ], + ).paddingHorizontal(16)).paddingTop(8); } Widget buildInformation() { @@ -181,7 +195,6 @@ class _UserInfoPageState extends LoadingState { buildItem(icon: MdIcons.location_city_outlined, title: "Region", content: data!.region), buildItem(icon: MdIcons.work_outline, title: "Job".tl, content: data!.job), buildItem(icon: MdIcons.person_2_outlined, title: "Gender".tl, content: data!.gender), - const SizedBox(height: 8,), buildHeader("Social Network".tl), buildItem(title: "Webpage", content: data!.webpage,