From e21755322f2cd5f1fba00c5c8cd5c5d1f79e459d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Aug 2022 12:52:15 +0600 Subject: [PATCH] feat(download): track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog --- lib/components/Home/Home.dart | 19 ++++ .../Shared/DownloadConfirmationDialog.dart | 92 +++++++++++++++++++ .../Shared/DownloadTrackButton.dart | 49 ++++++---- lib/components/Shared/TracksTableView.dart | 63 +++++++++---- lib/provider/Downloader.dart | 14 ++- lib/provider/Playback.dart | 9 +- 6 files changed, 204 insertions(+), 42 deletions(-) create mode 100644 lib/components/Shared/DownloadConfirmationDialog.dart diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index 1fb3e1383..517d81854 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/Home/SpotubeNavigationBar.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart'; import 'package:spotube/components/Lyrics/SyncedLyrics.dart'; import 'package:spotube/components/Search/Search.dart'; +import 'package:spotube/components/Shared/DownloadTrackButton.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Player/Player.dart'; import 'package:spotube/components/Library/UserLibrary.dart'; @@ -21,6 +22,7 @@ import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/usePaginatedFutureProvider.dart'; import 'package:spotube/hooks/useUpdateChecker.dart'; import 'package:spotube/models/Logger.dart'; +import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/utils/platform.dart'; @@ -53,6 +55,23 @@ class Home extends HookConsumerWidget { final _selectedIndex = useState(0); _onSelectedIndexChanged(int index) => _selectedIndex.value = index; + final downloader = ref.watch(downloaderProvider); + final isMounted = useIsMounted(); + + useEffect(() { + downloader.onFileExists = (track) async { + if (!isMounted()) return false; + return await showDialog( + context: context, + builder: (context) => ReplaceDownloadedFileDialog( + track: track, + ), + ) ?? + false; + }; + return null; + }, [downloader]); + // checks for latest version of the application useUpdateChecker(ref); diff --git a/lib/components/Shared/DownloadConfirmationDialog.dart b/lib/components/Shared/DownloadConfirmationDialog.dart new file mode 100644 index 000000000..4d7f61fdd --- /dev/null +++ b/lib/components/Shared/DownloadConfirmationDialog.dart @@ -0,0 +1,92 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +class DownloadConfirmationDialog extends StatelessWidget { + const DownloadConfirmationDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + contentPadding: const EdgeInsets.all(15), + title: Row( + children: [ + const Text("Are you sure?"), + const SizedBox(width: 10), + CachedNetworkImage( + imageUrl: + "https://c.tenor.com/kHcmsxlKHEAAAAAM/rock-one-eyebrow-raised-rock-staring.gif", + height: 40, + width: 40, + ) + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", + textAlign: TextAlign.justify, + ), + SizedBox(height: 10), + Text( + "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.justify, + ), + SizedBox(height: 10), + Text( + "By clicking 'accept' you agree to following terms:", + ), + SizedBox(height: 10), + BulletPoint("I know I'm pirating Music. I'm bad"), + SizedBox(height: 10), + BulletPoint( + "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art"), + SizedBox(height: 10), + BulletPoint( + "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action"), + ], + ), + ), + ), + actions: [ + ElevatedButton( + child: const Text("Decline"), + onPressed: () => Navigator.of(context).pop(false), + ), + ElevatedButton( + child: const Text("Accept"), + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + primary: Colors.red, + onPrimary: Colors.white, + ), + ) + ], + ); + } +} + +class BulletPoint extends StatelessWidget { + final String text; + const BulletPoint(this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("●"), + const SizedBox(width: 5), + Flexible(child: Text(text)), + ], + ); + } +} diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart index 60de226aa..e768d8407 100644 --- a/lib/components/Shared/DownloadTrackButton.dart +++ b/lib/components/Shared/DownloadTrackButton.dart @@ -67,25 +67,7 @@ class DownloadTrackButton extends HookConsumerWidget { final shouldReplace = await showDialog( context: context, builder: (context) { - return AlertDialog( - title: const Text("Track Already Exists"), - content: const Text( - "Do you want to replace the already downloaded track?"), - actions: [ - TextButton( - child: const Text("No"), - onPressed: () { - Navigator.pop(context, false); - }, - ), - TextButton( - child: const Text("Yes"), - onPressed: () { - Navigator.pop(context, true); - }, - ) - ], - ); + return ReplaceDownloadedFileDialog(track: track!); }, ); if (shouldReplace != true) return; @@ -209,3 +191,32 @@ class DownloadTrackButton extends HookConsumerWidget { ); } } + +class ReplaceDownloadedFileDialog extends StatelessWidget { + final Track track; + const ReplaceDownloadedFileDialog({required this.track, Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("Track ${track.name} Already Exists"), + content: + const Text("Do you want to replace the already downloaded track?"), + actions: [ + TextButton( + child: const Text("No"), + onPressed: () { + Navigator.pop(context, false); + }, + ), + TextButton( + child: const Text("Yes"), + onPressed: () { + Navigator.pop(context, true); + }, + ) + ], + ); + } +} diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index 81f2310a1..670c37ec3 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:queue/queue.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Shared/DownloadConfirmationDialog.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Downloader.dart'; @@ -50,6 +52,7 @@ class TracksTableView extends HookConsumerWidget { selected.value = tracks.map((s) => s.id!).toList(); } else { selected.value = []; + showCheck.value = false; } }, ), @@ -96,32 +99,49 @@ class TracksTableView extends HookConsumerWidget { itemBuilder: (context) { return [ PopupMenuItem( + enabled: selected.value.isNotEmpty, child: Row( children: const [ Icon(Icons.file_download_outlined), Text("Download"), ], ), - onTap: () async { - final spotubeTracks = await Future.wait( - tracks - .where( - (track) => selected.value.contains(track.id), - ) - .map((track) { - return Future.delayed(const Duration(seconds: 2), - () => playback.toSpotubeTrack(track)); - }), - ); - - for (var spotubeTrack in spotubeTracks) { - downloader.addToQueue(spotubeTrack); - } - }, value: "download", ), ]; }, + onSelected: (action) async { + switch (action) { + case "download": + { + final isConfirmed = await showDialog( + context: context, + builder: (context) { + return const DownloadConfirmationDialog(); + }); + if (isConfirmed != true) return; + final queue = Queue( + delay: const Duration(seconds: 5), + ); + final selectedTracks = tracks.where( + (track) => selected.value.contains(track.id), + ); + for (final selectedTrack in selectedTracks) { + queue.add(() async { + downloader.addToQueue( + await playback.toSpotubeTrack( + selectedTrack, + noSponsorBlock: true, + ), + ); + }); + } + await queue.onComplete; + break; + } + default: + } + }, ), ], ), @@ -132,11 +152,18 @@ class TracksTableView extends HookConsumerWidget { ); String duration = "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return GestureDetector( - onDoubleTap: () { + return InkWell( + onLongPress: () { showCheck.value = true; selected.value = [...selected.value, track.value.id!]; }, + onTap: () { + if (showCheck.value) { + selected.value = [...selected.value, track.value.id!]; + } else { + onTrackPlayButtonPressed?.call(track.value); + } + }, child: TrackTile( playback, playlistId: playlistId, diff --git a/lib/provider/Downloader.dart b/lib/provider/Downloader.dart index 96a783dfe..16537b170 100644 --- a/lib/provider/Downloader.dart +++ b/lib/provider/Downloader.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/widgets.dart'; @@ -9,29 +10,35 @@ import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -Queue _queueInstance = Queue(delay: const Duration(seconds: 1)); +Queue _queueInstance = Queue(delay: const Duration(seconds: 5)); class Downloader with ChangeNotifier { Queue _queue; YoutubeExplode yt; String downloadPath; + FutureOr Function(SpotubeTrack track)? onFileExists; Downloader( this._queue, { required this.downloadPath, required this.yt, + this.onFileExists, }); int currentlyRunning = 0; + Set inQueue = {}; void addToQueue(SpotubeTrack track) { + if (inQueue.contains(track.id!)) return; currentlyRunning++; + inQueue.add(track.id!); notifyListeners(); _queue.add(() async { try { final file = File(path.join(downloadPath, '${track.ytTrack.title}.mp3')); - // TODO find a way to let the UI know there's already provided file is available - if (file.existsSync()) return; + if (file.existsSync() && await onFileExists?.call(track) != true) { + return; + } file.createSync(recursive: true); StreamManifest manifest = await yt.videos.streamsClient.getManifest(track.ytTrack.url); @@ -48,6 +55,7 @@ class Downloader with ChangeNotifier { await outputFileStream.flush(); } finally { currentlyRunning--; + inQueue.remove(track.id); notifyListeners(); } }); diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart index af1a0eff7..147f569dc 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -333,7 +333,10 @@ class Playback extends PersistedChangeNotifier { } // playlist & track list methods - Future toSpotubeTrack(Track track) async { + Future toSpotubeTrack( + Track track, { + bool noSponsorBlock = false, + }) async { try { final format = preferences.ytSearchFormat; final matchAlgorithm = preferences.trackMatchAlgorithm; @@ -452,7 +455,9 @@ class Playback extends PersistedChangeNotifier { (segment) => segment.toJson(), ) .toList() - : await getSkipSegments(ytVideo.id.value); + : noSponsorBlock + ? List.castFrom>([]) + : await getSkipSegments(ytVideo.id.value); // only save when the track isn't available in the cache with same // matchAlgorithm