diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index fdf46051..b345660a 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -87,7 +87,12 @@ class PlaylistView extends HookConsumerWidget { onPlay: ([track]) { if (tracksSnapshot.hasData) { if (!isPlaylistPlaying) { - playPlaylist(playback, tracksSnapshot.data!, ref); + playPlaylist( + playback, + tracksSnapshot.data!, + ref, + currentTrack: track, + ); } else if (isPlaylistPlaying && track != null) { playPlaylist( playback, diff --git a/lib/components/Shared/AdaptivePopupMenuButton.dart b/lib/components/Shared/AdaptivePopupMenuButton.dart index 5dd34b3e..35752292 100644 --- a/lib/components/Shared/AdaptivePopupMenuButton.dart +++ b/lib/components/Shared/AdaptivePopupMenuButton.dart @@ -6,7 +6,7 @@ import 'package:spotube/hooks/useBreakpoints.dart'; class Action extends StatelessWidget { final Widget text; - final Icon icon; + final Widget icon; final void Function() onPressed; final bool isExpanded; const Action({ diff --git a/lib/components/Shared/AddTracksToPlaylistDialog.dart b/lib/components/Shared/AddTracksToPlaylistDialog.dart new file mode 100644 index 00000000..ba27b366 --- /dev/null +++ b/lib/components/Shared/AddTracksToPlaylistDialog.dart @@ -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 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({}); + + 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 + }; + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 232faa56..c40d0c56 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -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/services.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/Playback.dart'; import 'package:spotube/provider/SpotifyDI.dart'; +import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:tuple/tuple.dart'; @@ -55,18 +58,21 @@ class TrackTile extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final isReallyLocal = isLocal || - ref.watch( - playbackProvider.select((s) => s.playlist?.isLocal == true), - ); final breakpoint = useBreakpoints(); final auth = ref.watch(authProvider); final spotify = ref.watch(spotifyProvider); - - final actionRemoveFromPlaylist = useCallback(() async { - if (playlistId == null) return; - return await spotify.playlists.removeTrack(track.value.uri!, playlistId!); - }, [playlistId, spotify, track.value.uri]); + final removingTrack = useState(null); + final removeTrack = useMutation>( + job: removeTrackFromPlaylistMutationJob(playlistId ?? ""), + onData: (payload, variables, ctx) { + if (playlistId == null || !payload) return; + QueryBowl.of(context) + .getQuery( + playlistTracksQueryJob(playlistId!).queryKey, + ) + ?.refetch(); + }, + ); void actionShare(Track track) { final data = "https://open.spotify.com/track/${track.id}"; @@ -244,7 +250,7 @@ class TrackTile extends HookConsumerWidget { ), overflow: TextOverflow.ellipsis, ), - isReallyLocal + isLocal ? PlatformText( TypeConversionUtils.artists_X_String( track.value.artists ?? []), @@ -260,7 +266,7 @@ class TrackTile extends HookConsumerWidget { ), if (breakpoint.isMoreThan(Breakpoints.md) && showAlbum) Expanded( - child: isReallyLocal + child: isLocal ? PlatformText(track.value.album?.name ?? "") : LinkText( track.value.album!.name!, @@ -274,7 +280,7 @@ class TrackTile extends HookConsumerWidget { PlatformText(duration), ], const SizedBox(width: 10), - if (!isReallyLocal) + if (!isLocal) AdaptiveActions( actions: [ if (toggler.item3.hasData) @@ -298,9 +304,17 @@ class TrackTile extends HookConsumerWidget { ), if (userPlaylist && auth.isLoggedIn) 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"), - onPressed: actionRemoveFromPlaylist, + onPressed: () { + removingTrack.value = track.value.uri; + removeTrack.mutate(Tuple2(spotify, track.value.uri!)); + }, ), Action( icon: const Icon(Icons.share_rounded), diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index 4dea4e04..a17930b0 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotify/spotify.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/NotFound.dart'; import 'package:spotube/components/Shared/SortTracksDropdown.dart'; @@ -123,14 +124,16 @@ class TracksTableView extends HookConsumerWidget { value: sortBy, onChanged: (value) { ref - .read(trackCollectionSortState(playlistId ?? '').state) + .read( + trackCollectionSortState(playlistId ?? '').notifier) .state = value; }, ), PlatformPopupMenuButton( + closeAfterClick: false, items: [ PlatformPopupMenuItem( - enabled: selected.value.isNotEmpty, + enabled: selectedTracks.isNotEmpty, value: "download", child: Row( 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 { switch (action) { case "download": { - final isConfirmed = await showPlatformAlertDialog( - context, builder: (context) { - return const DownloadConfirmationDialog(); - }); - if (isConfirmed != true) return; + final confirmed = await showPlatformAlertDialog( + context, + builder: (context) { + return const DownloadConfirmationDialog(); + }, + ); + if (confirmed != true) return; for (final selectedTrack in selectedTracks) { downloader.addToQueue(selectedTrack); } @@ -158,11 +176,24 @@ class TracksTableView extends HookConsumerWidget { showCheck.value = false; break; } + case "add-to-playlist": + { + await showPlatformAlertDialog( + context, + builder: (context) { + return AddTracksToPlaylistDialog( + tracks: selectedTracks.toList(), + ); + }, + ); + break; + } default: } }, child: const Icon(Icons.more_vert), ), + const SizedBox(width: 10), ], ), ...sortedTracks.asMap().entries.map((track) { diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index af60326b..3cbc3287 100644 --- a/lib/provider/SpotifyRequests.dart +++ b/lib/provider/SpotifyRequests.dart @@ -276,3 +276,16 @@ final toggleFavoriteAlbumMutationJob = return !isLiked; }, ); + +final removeTrackFromPlaylistMutationJob = + MutationJob.withVariableKey>( + 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; + }, +);