mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
feat: add selected tracks to playlists, optimistic playlist remove track
This commit is contained in:
parent
ee5c417ac3
commit
3386dac78e
@ -87,7 +87,12 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
onPlay: ([track]) {
|
onPlay: ([track]) {
|
||||||
if (tracksSnapshot.hasData) {
|
if (tracksSnapshot.hasData) {
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playPlaylist(playback, tracksSnapshot.data!, ref);
|
playPlaylist(
|
||||||
|
playback,
|
||||||
|
tracksSnapshot.data!,
|
||||||
|
ref,
|
||||||
|
currentTrack: track,
|
||||||
|
);
|
||||||
} else if (isPlaylistPlaying && track != null) {
|
} else if (isPlaylistPlaying && track != null) {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
playback,
|
playback,
|
||||||
|
@ -6,7 +6,7 @@ import 'package:spotube/hooks/useBreakpoints.dart';
|
|||||||
|
|
||||||
class Action extends StatelessWidget {
|
class Action extends StatelessWidget {
|
||||||
final Widget text;
|
final Widget text;
|
||||||
final Icon icon;
|
final Widget icon;
|
||||||
final void Function() onPressed;
|
final void Function() onPressed;
|
||||||
final bool isExpanded;
|
final bool isExpanded;
|
||||||
const Action({
|
const Action({
|
||||||
|
90
lib/components/Shared/AddTracksToPlaylistDialog.dart
Normal file
90
lib/components/Shared/AddTracksToPlaylistDialog.dart
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
import 'package:spotube/provider/SpotifyRequests.dart';
|
||||||
|
|
||||||
|
class AddTracksToPlaylistDialog extends HookConsumerWidget {
|
||||||
|
final List<Track> tracks;
|
||||||
|
const AddTracksToPlaylistDialog({
|
||||||
|
required this.tracks,
|
||||||
|
Key? key,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
final userPlaylists = useQuery(
|
||||||
|
job: currentUserPlaylistsQueryJob,
|
||||||
|
externalData: spotify,
|
||||||
|
);
|
||||||
|
final me = useQuery(
|
||||||
|
job: currentUserQueryJob,
|
||||||
|
externalData: spotify,
|
||||||
|
);
|
||||||
|
final filteredPlaylists = userPlaylists.data?.where(
|
||||||
|
(playlist) =>
|
||||||
|
playlist.owner?.id != null && playlist.owner!.id == me.data?.id,
|
||||||
|
);
|
||||||
|
final playlistsCheck = useState(<String, bool>{});
|
||||||
|
|
||||||
|
return PlatformAlertDialog(
|
||||||
|
title: const PlatformText("Add to Playlist"),
|
||||||
|
secondaryActions: [
|
||||||
|
PlatformFilledButton(
|
||||||
|
isSecondary: true,
|
||||||
|
child: const PlatformText("Cancel"),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
primaryActions: [
|
||||||
|
PlatformFilledButton(
|
||||||
|
child: const PlatformText("Add"),
|
||||||
|
onPressed: () async {
|
||||||
|
final selectedPlaylists = playlistsCheck.value.entries
|
||||||
|
.where((entry) => entry.value)
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
|
||||||
|
await Future.wait(
|
||||||
|
selectedPlaylists.map(
|
||||||
|
(playlistId) => spotify.playlists.addTracks(
|
||||||
|
tracks
|
||||||
|
.map(
|
||||||
|
(track) => track.uri!,
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
playlistId),
|
||||||
|
),
|
||||||
|
).then((_) => Navigator.pop(context));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
content: SizedBox(
|
||||||
|
height: 300,
|
||||||
|
width: 300,
|
||||||
|
child: !userPlaylists.hasData
|
||||||
|
? const Center(child: PlatformCircularProgressIndicator())
|
||||||
|
: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: filteredPlaylists!.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = filteredPlaylists.elementAt(index);
|
||||||
|
return PlatformCheckbox(
|
||||||
|
label: PlatformText(playlist.name!),
|
||||||
|
value: playlistsCheck.value[playlist.id] ?? false,
|
||||||
|
onChanged: (val) {
|
||||||
|
playlistsCheck.value = {
|
||||||
|
...playlistsCheck.value,
|
||||||
|
playlist.id!: val == true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import 'package:fl_query/fl_query.dart';
|
||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
import 'package:flutter/material.dart' hide Action;
|
import 'package:flutter/material.dart' hide Action;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@ -14,6 +16,7 @@ import 'package:spotube/models/Logger.dart';
|
|||||||
import 'package:spotube/provider/Auth.dart';
|
import 'package:spotube/provider/Auth.dart';
|
||||||
import 'package:spotube/provider/Playback.dart';
|
import 'package:spotube/provider/Playback.dart';
|
||||||
import 'package:spotube/provider/SpotifyDI.dart';
|
import 'package:spotube/provider/SpotifyDI.dart';
|
||||||
|
import 'package:spotube/provider/SpotifyRequests.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
@ -55,18 +58,21 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final isReallyLocal = isLocal ||
|
|
||||||
ref.watch(
|
|
||||||
playbackProvider.select((s) => s.playlist?.isLocal == true),
|
|
||||||
);
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
final removingTrack = useState<String?>(null);
|
||||||
final actionRemoveFromPlaylist = useCallback(() async {
|
final removeTrack = useMutation<bool, Tuple2<SpotifyApi, String>>(
|
||||||
if (playlistId == null) return;
|
job: removeTrackFromPlaylistMutationJob(playlistId ?? ""),
|
||||||
return await spotify.playlists.removeTrack(track.value.uri!, playlistId!);
|
onData: (payload, variables, ctx) {
|
||||||
}, [playlistId, spotify, track.value.uri]);
|
if (playlistId == null || !payload) return;
|
||||||
|
QueryBowl.of(context)
|
||||||
|
.getQuery(
|
||||||
|
playlistTracksQueryJob(playlistId!).queryKey,
|
||||||
|
)
|
||||||
|
?.refetch();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void actionShare(Track track) {
|
void actionShare(Track track) {
|
||||||
final data = "https://open.spotify.com/track/${track.id}";
|
final data = "https://open.spotify.com/track/${track.id}";
|
||||||
@ -244,7 +250,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
isReallyLocal
|
isLocal
|
||||||
? PlatformText(
|
? PlatformText(
|
||||||
TypeConversionUtils.artists_X_String<Artist>(
|
TypeConversionUtils.artists_X_String<Artist>(
|
||||||
track.value.artists ?? []),
|
track.value.artists ?? []),
|
||||||
@ -260,7 +266,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (breakpoint.isMoreThan(Breakpoints.md) && showAlbum)
|
if (breakpoint.isMoreThan(Breakpoints.md) && showAlbum)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: isReallyLocal
|
child: isLocal
|
||||||
? PlatformText(track.value.album?.name ?? "")
|
? PlatformText(track.value.album?.name ?? "")
|
||||||
: LinkText(
|
: LinkText(
|
||||||
track.value.album!.name!,
|
track.value.album!.name!,
|
||||||
@ -274,7 +280,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
PlatformText(duration),
|
PlatformText(duration),
|
||||||
],
|
],
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
if (!isReallyLocal)
|
if (!isLocal)
|
||||||
AdaptiveActions(
|
AdaptiveActions(
|
||||||
actions: [
|
actions: [
|
||||||
if (toggler.item3.hasData)
|
if (toggler.item3.hasData)
|
||||||
@ -298,9 +304,17 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (userPlaylist && auth.isLoggedIn)
|
if (userPlaylist && auth.isLoggedIn)
|
||||||
Action(
|
Action(
|
||||||
icon: const Icon(Icons.remove_circle_outline_rounded),
|
icon: removeTrack.isLoading &&
|
||||||
|
removingTrack.value == track.value.uri
|
||||||
|
? const Center(
|
||||||
|
child: PlatformCircularProgressIndicator(),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.remove_circle_outline_rounded),
|
||||||
text: const PlatformText("Remove from playlist"),
|
text: const PlatformText("Remove from playlist"),
|
||||||
onPressed: actionRemoveFromPlaylist,
|
onPressed: () {
|
||||||
|
removingTrack.value = track.value.uri;
|
||||||
|
removeTrack.mutate(Tuple2(spotify, track.value.uri!));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
icon: const Icon(Icons.share_rounded),
|
icon: const Icon(Icons.share_rounded),
|
||||||
|
@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
import 'package:spotube/components/Library/UserLocalTracks.dart';
|
||||||
|
import 'package:spotube/components/Shared/AddTracksToPlaylistDialog.dart';
|
||||||
import 'package:spotube/components/Shared/DownloadConfirmationDialog.dart';
|
import 'package:spotube/components/Shared/DownloadConfirmationDialog.dart';
|
||||||
import 'package:spotube/components/Shared/NotFound.dart';
|
import 'package:spotube/components/Shared/NotFound.dart';
|
||||||
import 'package:spotube/components/Shared/SortTracksDropdown.dart';
|
import 'package:spotube/components/Shared/SortTracksDropdown.dart';
|
||||||
@ -123,14 +124,16 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
value: sortBy,
|
value: sortBy,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref
|
ref
|
||||||
.read(trackCollectionSortState(playlistId ?? '').state)
|
.read(
|
||||||
|
trackCollectionSortState(playlistId ?? '').notifier)
|
||||||
.state = value;
|
.state = value;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PlatformPopupMenuButton(
|
PlatformPopupMenuButton(
|
||||||
|
closeAfterClick: false,
|
||||||
items: [
|
items: [
|
||||||
PlatformPopupMenuItem(
|
PlatformPopupMenuItem(
|
||||||
enabled: selected.value.isNotEmpty,
|
enabled: selectedTracks.isNotEmpty,
|
||||||
value: "download",
|
value: "download",
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -141,16 +144,31 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!userPlaylist)
|
||||||
|
PlatformPopupMenuItem(
|
||||||
|
enabled: selectedTracks.isNotEmpty,
|
||||||
|
value: "add-to-playlist",
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.playlist_add_rounded),
|
||||||
|
PlatformText(
|
||||||
|
"Add (${selectedTracks.length}) to Playlist",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onSelected: (action) async {
|
onSelected: (action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "download":
|
case "download":
|
||||||
{
|
{
|
||||||
final isConfirmed = await showPlatformAlertDialog(
|
final confirmed = await showPlatformAlertDialog(
|
||||||
context, builder: (context) {
|
context,
|
||||||
return const DownloadConfirmationDialog();
|
builder: (context) {
|
||||||
});
|
return const DownloadConfirmationDialog();
|
||||||
if (isConfirmed != true) return;
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
for (final selectedTrack in selectedTracks) {
|
for (final selectedTrack in selectedTracks) {
|
||||||
downloader.addToQueue(selectedTrack);
|
downloader.addToQueue(selectedTrack);
|
||||||
}
|
}
|
||||||
@ -158,11 +176,24 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
showCheck.value = false;
|
showCheck.value = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "add-to-playlist":
|
||||||
|
{
|
||||||
|
await showPlatformAlertDialog(
|
||||||
|
context,
|
||||||
|
builder: (context) {
|
||||||
|
return AddTracksToPlaylistDialog(
|
||||||
|
tracks: selectedTracks.toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.more_vert),
|
child: const Icon(Icons.more_vert),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
...sortedTracks.asMap().entries.map((track) {
|
...sortedTracks.asMap().entries.map((track) {
|
||||||
|
@ -276,3 +276,16 @@ final toggleFavoriteAlbumMutationJob =
|
|||||||
return !isLiked;
|
return !isLiked;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final removeTrackFromPlaylistMutationJob =
|
||||||
|
MutationJob.withVariableKey<bool, Tuple2<SpotifyApi, String>>(
|
||||||
|
preMutationKey: "remove-track-from-playlist",
|
||||||
|
task: (queryKey, externalData) async {
|
||||||
|
final spotify = externalData.item1;
|
||||||
|
final playlistId = getVariable(queryKey);
|
||||||
|
final trackId = externalData.item2;
|
||||||
|
|
||||||
|
await spotify.playlists.removeTracks([trackId], playlistId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user