mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
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:
parent
ff369c7400
commit
e21755322f
@ -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);
|
||||||
|
|
||||||
|
92
lib/components/Shared/DownloadConfirmationDialog.dart
Normal file
92
lib/components/Shared/DownloadConfirmationDialog.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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,7 +455,9 @@ class Playback extends PersistedChangeNotifier {
|
|||||||
(segment) => segment.toJson(),
|
(segment) => segment.toJson(),
|
||||||
)
|
)
|
||||||
.toList()
|
.toList()
|
||||||
: await getSkipSegments(ytVideo.id.value);
|
: noSponsorBlock
|
||||||
|
? List.castFrom<dynamic, Map<String, int>>([])
|
||||||
|
: 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
|
||||||
// matchAlgorithm
|
// matchAlgorithm
|
||||||
|
Loading…
Reference in New Issue
Block a user