feat(download): track table view multi select improvement, tap to play track support, existing track replace confirmation dialog and bulk download confirmation dialog

This commit is contained in:
Kingkor Roy Tirtho 2022-08-09 12:52:15 +06:00
parent ff369c7400
commit e21755322f
6 changed files with 204 additions and 42 deletions

View File

@ -14,6 +14,7 @@ import 'package:spotube/components/Home/SpotubeNavigationBar.dart';
import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart';
import 'package:spotube/components/Lyrics/SyncedLyrics.dart'; import 'package:spotube/components/Lyrics/SyncedLyrics.dart';
import 'package:spotube/components/Search/Search.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/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Player/Player.dart'; import 'package:spotube/components/Player/Player.dart';
import 'package:spotube/components/Library/UserLibrary.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/usePaginatedFutureProvider.dart';
import 'package:spotube/hooks/useUpdateChecker.dart'; import 'package:spotube/hooks/useUpdateChecker.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Downloader.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -53,6 +55,23 @@ class Home extends HookConsumerWidget {
final _selectedIndex = useState(0); final _selectedIndex = useState(0);
_onSelectedIndexChanged(int index) => _selectedIndex.value = index; _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<bool>(
context: context,
builder: (context) => ReplaceDownloadedFileDialog(
track: track,
),
) ??
false;
};
return null;
}, [downloader]);
// checks for latest version of the application // checks for latest version of the application
useUpdateChecker(ref); useUpdateChecker(ref);

View File

@ -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)),
],
);
}
}

View File

@ -67,25 +67,7 @@ class DownloadTrackButton extends HookConsumerWidget {
final shouldReplace = await showDialog<bool>( final shouldReplace = await showDialog<bool>(
context: context, context: context,
builder: (context) { builder: (context) {
return AlertDialog( return ReplaceDownloadedFileDialog(track: track!);
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);
},
)
],
);
}, },
); );
if (shouldReplace != true) return; 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);
},
)
],
);
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:queue/queue.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/DownloadConfirmationDialog.dart';
import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/components/Shared/TrackTile.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/Downloader.dart';
@ -50,6 +52,7 @@ class TracksTableView extends HookConsumerWidget {
selected.value = tracks.map((s) => s.id!).toList(); selected.value = tracks.map((s) => s.id!).toList();
} else { } else {
selected.value = []; selected.value = [];
showCheck.value = false;
} }
}, },
), ),
@ -96,32 +99,49 @@ class TracksTableView extends HookConsumerWidget {
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
PopupMenuItem( PopupMenuItem(
enabled: selected.value.isNotEmpty,
child: Row( child: Row(
children: const [ children: const [
Icon(Icons.file_download_outlined), Icon(Icons.file_download_outlined),
Text("Download"), 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", 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 = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return GestureDetector( return InkWell(
onDoubleTap: () { onLongPress: () {
showCheck.value = true; showCheck.value = true;
selected.value = [...selected.value, track.value.id!]; 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( child: TrackTile(
playback, playback,
playlistId: playlistId, playlistId: playlistId,

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -9,29 +10,35 @@ import 'package:spotube/provider/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/provider/YouTube.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.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 { class Downloader with ChangeNotifier {
Queue _queue; Queue _queue;
YoutubeExplode yt; YoutubeExplode yt;
String downloadPath; String downloadPath;
FutureOr<bool> Function(SpotubeTrack track)? onFileExists;
Downloader( Downloader(
this._queue, { this._queue, {
required this.downloadPath, required this.downloadPath,
required this.yt, required this.yt,
this.onFileExists,
}); });
int currentlyRunning = 0; int currentlyRunning = 0;
Set<String> inQueue = {};
void addToQueue(SpotubeTrack track) { void addToQueue(SpotubeTrack track) {
if (inQueue.contains(track.id!)) return;
currentlyRunning++; currentlyRunning++;
inQueue.add(track.id!);
notifyListeners(); notifyListeners();
_queue.add(() async { _queue.add(() async {
try { try {
final file = final file =
File(path.join(downloadPath, '${track.ytTrack.title}.mp3')); 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() && await onFileExists?.call(track) != true) {
if (file.existsSync()) return; return;
}
file.createSync(recursive: true); file.createSync(recursive: true);
StreamManifest manifest = StreamManifest manifest =
await yt.videos.streamsClient.getManifest(track.ytTrack.url); await yt.videos.streamsClient.getManifest(track.ytTrack.url);
@ -48,6 +55,7 @@ class Downloader with ChangeNotifier {
await outputFileStream.flush(); await outputFileStream.flush();
} finally { } finally {
currentlyRunning--; currentlyRunning--;
inQueue.remove(track.id);
notifyListeners(); notifyListeners();
} }
}); });

View File

@ -333,7 +333,10 @@ class Playback extends PersistedChangeNotifier {
} }
// playlist & track list methods // playlist & track list methods
Future<SpotubeTrack> toSpotubeTrack(Track track) async { Future<SpotubeTrack> toSpotubeTrack(
Track track, {
bool noSponsorBlock = false,
}) async {
try { try {
final format = preferences.ytSearchFormat; final format = preferences.ytSearchFormat;
final matchAlgorithm = preferences.trackMatchAlgorithm; final matchAlgorithm = preferences.trackMatchAlgorithm;
@ -452,6 +455,8 @@ class Playback extends PersistedChangeNotifier {
(segment) => segment.toJson(), (segment) => segment.toJson(),
) )
.toList() .toList()
: noSponsorBlock
? List.castFrom<dynamic, Map<String, int>>([])
: await getSkipSegments(ytVideo.id.value); : await getSkipSegments(ytVideo.id.value);
// only save when the track isn't available in the cache with same // only save when the track isn't available in the cache with same