diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 4b72aa6ac..fa31427b8 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -25,6 +25,8 @@ abstract class SpotubeIcons { static const heart = FeatherIcons.heart; static const heartFilled = Icons.favorite_rounded; static const queue = Icons.queue_music_rounded; + static const queueAdd = Icons.add_to_photos_outlined; + static const queueRemove = Icons.remove_outlined; static const download = FeatherIcons.download; static const done = FeatherIcons.checkCircle; static const alternativeRoute = Icons.alt_route_rounded; diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 14da13543..acdb266d9 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -1,13 +1,16 @@ +import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/playlist_queue_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:uuid/uuid.dart'; class AlbumCard extends HookConsumerWidget { final Album album; @@ -23,39 +26,66 @@ class AlbumCard extends HookConsumerWidget { final playlist = ref.watch(PlaylistQueueNotifier.provider); final playing = useStream(PlaylistQueueNotifier.playing).data ?? false; final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); - bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(album.tracks!); + final queryBowl = QueryBowl.of(context); + final query = queryBowl.getQuery, SpotifyApi>( + Queries.album.tracksOf(album.id!).queryKey); + final tracks = useState(query?.data ?? album.tracks ?? []); + bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks.value); final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, - placeholder: ImagePlaceholder.collection, - ), - viewType: viewType, - margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), - isPlaying: isPlaylistPlaying && playing, - isLoading: isPlaylistPlaying && playlist?.isLoading == true, - title: album.name!, - description: - "Album • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", - onTap: () { - ServiceUtils.navigate(context, "/album/${album.id}", extra: album); - }, - onPlaybuttonPressed: () async { - SpotifyApi spotify = ref.read(spotifyProvider); - if (isPlaylistPlaying && playing) { - return playlistNotifier.pause(); - } else if (isPlaylistPlaying && !playing) { - return playlistNotifier.resume(); - } - List tracks = (await spotify.albums.getTracks(album.id!).all()) - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - if (tracks.isEmpty) return; + imageUrl: TypeConversionUtils.image_X_UrlString( + album.images, + placeholder: ImagePlaceholder.collection, + ), + viewType: viewType, + margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), + isPlaying: isPlaylistPlaying && playing, + isLoading: isPlaylistPlaying && playlist?.isLoading == true, + title: album.name!, + description: + "Album • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + onTap: () { + ServiceUtils.navigate(context, "/album/${album.id}", extra: album); + }, + onPlaybuttonPressed: () async { + if (isPlaylistPlaying && playing) { + return playlistNotifier.pause(); + } else if (isPlaylistPlaying && !playing) { + return playlistNotifier.resume(); + } + + await playlistNotifier.loadAndPlay(album.tracks + ?.map( + (e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) + .toList() ?? + []); + }, + onAddToQueuePressed: () async { + if (isPlaylistPlaying) { + return; + } + + final fetchedTracks = + await queryBowl.fetchQuery, SpotifyApi>( + Queries.album.tracksOf(album.id!), + externalData: ref.read(spotifyProvider), + key: ValueKey(const Uuid().v4()), + ); + + if (fetchedTracks == null || fetchedTracks.isEmpty) return; - await playlistNotifier.loadAndPlay(tracks); - }, - ); + playlistNotifier.add( + fetchedTracks + .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) + .toList(), + ); + tracks.value = fetchedTracks; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Added ${album.tracks?.length} tracks to queue"), + ), + ); + }); } } diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 29781eacc..2f942bd65 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -70,6 +70,25 @@ class PlaylistCard extends HookConsumerWidget { await playlistNotifier.loadAndPlay(fetchedTracks); tracks.value = fetchedTracks; }, + onAddToQueuePressed: () async { + if (isPlaylistPlaying) return; + List fetchedTracks = await queryBowl.fetchQuery( + key: ValueKey(const Uuid().v4()), + Queries.playlist.tracksOf(playlist.id!), + externalData: ref.read(spotifyProvider), + ) ?? + []; + + if (fetchedTracks.isEmpty) return; + + playlistNotifier.add(fetchedTracks); + tracks.value = fetchedTracks; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Added ${fetchedTracks.length} tracks to queue"), + ), + ); + }, ); } } diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 6a4203b2c..0c6dd42f1 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -13,6 +13,7 @@ enum PlaybuttonCardViewType { square, list } class PlaybuttonCard extends HookWidget { final void Function()? onTap; final void Function()? onPlaybuttonPressed; + final void Function()? onAddToQueuePressed; final String? description; final EdgeInsetsGeometry? margin; final String imageUrl; @@ -29,6 +30,7 @@ class PlaybuttonCard extends HookWidget { this.margin, this.description, this.onPlaybuttonPressed, + this.onAddToQueuePressed, this.onTap, this.viewType = PlaybuttonCardViewType.square, Key? key, @@ -101,6 +103,15 @@ class PlaybuttonCard extends HookWidget { color: Colors.white, ), ); + final addToQueueButton = PlatformIconButton( + onPressed: onAddToQueuePressed, + backgroundColor: + PlatformTheme.of(context).secondaryBackgroundColor, + hoverColor: PlatformTheme.of(context) + .secondaryBackgroundColor + ?.withOpacity(0.5), + icon: const Icon(SpotubeIcons.queueAdd), + ); final image = Padding( padding: EdgeInsets.all( platform == TargetPlatform.windows ? 5 : 0, @@ -131,7 +142,16 @@ class PlaybuttonCard extends HookWidget { textDirection: TextDirection.ltr, bottom: 10, end: 5, - child: playButton, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!isPlaying) addToQueueButton, + if (platform != TargetPlatform.linux) + const SizedBox(width: 5), + playButton, + ], + ), ) ], ), @@ -192,6 +212,8 @@ class PlaybuttonCard extends HookWidget { ], ), const Spacer(), + addToQueueButton, + const SizedBox(width: 10), playButton, const SizedBox(width: 10), ], diff --git a/lib/components/shared/track_table/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view.dart index 7e8c3bab6..d242f501f 100644 --- a/lib/components/shared/track_table/track_collection_view.dart +++ b/lib/components/shared/track_table/track_collection_view.dart @@ -31,6 +31,7 @@ class TrackCollectionView extends HookConsumerWidget { final String titleImage; final bool isPlaying; final void Function([Track? currentTrack]) onPlay; + final void Function() onAddToQueue; final void Function([Track? currentTrack]) onShuffledPlay; final void Function() onShare; final Widget? heartBtn; @@ -49,6 +50,7 @@ class TrackCollectionView extends HookConsumerWidget { required this.isPlaying, required this.onPlay, required this.onShuffledPlay, + required this.onAddToQueue, required this.onShare, required this.routePath, this.heartBtn, @@ -87,14 +89,20 @@ class TrackCollectionView extends HookConsumerWidget { onPressed: onShuffledPlay, ), const SizedBox(width: 5), + // add to queue playlist + if (!isPlaying) + PlatformIconButton( + onPressed: tracksSnapshot.data != null ? onAddToQueue : null, + icon: Icon( + SpotubeIcons.queueAdd, + color: color?.titleTextColor, + ), + ), // play playlist PlatformIconButton( backgroundColor: PlatformTheme.of(context).primaryColor, onPressed: tracksSnapshot.data != null ? onPlay : null, - icon: Icon( - isPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - color: PlatformTextTheme.of(context).body?.color, - ), + icon: Icon(isPlaying ? SpotubeIcons.stop : SpotubeIcons.play), ), const SizedBox(width: 10), ]; diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index f266e9ec0..2977104c1 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -74,6 +74,8 @@ class TrackTile extends HookConsumerWidget { ); final auth = ref.watch(authProvider); final spotify = ref.watch(spotifyProvider); + final playlistQueueNotifier = ref.watch(PlaylistQueueNotifier.notifier); + final removingTrack = useState(null); final removeTrack = useMutation>( job: Mutations.playlist.removeTrackOf(playlistId ?? ""), @@ -319,6 +321,34 @@ class TrackTile extends HookConsumerWidget { if (!isLocal) AdaptiveActions( actions: [ + if (!playlistQueueNotifier.isTrackOnQueue(track.value)) + Action( + icon: const Icon(SpotubeIcons.queueAdd), + text: const PlatformText("Add to queue"), + onPressed: () { + playlistQueueNotifier.add([track.value]); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: PlatformText( + "Added ${track.value.name} to queue"), + ), + ); + }, + ) + else + Action( + icon: const Icon(SpotubeIcons.queueRemove), + text: const PlatformText("Remove from queue"), + onPressed: () { + playlistQueueNotifier.remove([track.value]); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: PlatformText( + "Removed ${track.value.name} from queue"), + ), + ); + }, + ), if (toggler.item3.hasData) Action( icon: toggler.item1 @@ -334,7 +364,7 @@ class TrackTile extends HookConsumerWidget { ), if (auth.isLoggedIn) Action( - icon: const Icon(SpotubeIcons.addFilled), + icon: const Icon(SpotubeIcons.playlistAdd), text: const PlatformText("Add To playlist"), onPressed: actionAddToPlaylist, ), diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 9999c9bca..14d1bb919 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -98,10 +98,25 @@ class AlbumPage extends HookConsumerWidget { ref, ); } else { - playback.stop(); + playback.remove( + tracksSnapshot.data! + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) + .toList(), + ); } } }, + onAddToQueue: () { + if (tracksSnapshot.hasData && !isAlbumPlaying) { + playback.add( + tracksSnapshot.data! + .map((track) => + TypeConversionUtils.simpleTrack_X_Track(track, album)) + .toList(), + ); + } + }, onShare: () { Clipboard.setData( ClipboardData(text: "https://open.spotify.com/album/${album.id}"), diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 3029b296c..4b0f68f20 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -323,6 +323,27 @@ class ArtistPage extends HookConsumerWidget { style: PlatformTheme.of(context).textTheme?.headline, ), + if (!isPlaylistPlaying) + PlatformIconButton( + icon: const Icon( + SpotubeIcons.queueAdd, + ), + onPressed: () { + playlistNotifier.add(topTracks.toList()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: PlatformText( + "Added ${topTracks.length} tracks to queue", + textAlign: TextAlign.center, + ), + ), + ); + }, + ), + if (platform != TargetPlatform.linux) + const SizedBox(width: 5), PlatformIconButton( icon: Icon( isPlaylistPlaying diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index d1a24ebc2..06c6244d8 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -92,10 +92,15 @@ class PlaylistView extends HookConsumerWidget { currentTrack: track, ); } else { - playlistNotifier.stop(); + playlistNotifier.remove(tracksSnapshot.data!); } } }, + onAddToQueue: () { + if (tracksSnapshot.hasData && !isPlaylistPlaying) { + playlistNotifier.add(tracksSnapshot.data!); + } + }, bottomSpace: breakpoint.isLessThanOrEqualTo(Breakpoints.md), showShare: playlist.id != "user-liked-tracks", routePath: "/playlist/${playlist.id}", diff --git a/lib/provider/playlist_queue_provider.dart b/lib/provider/playlist_queue_provider.dart index c002ff1cb..b191b856c 100644 --- a/lib/provider/playlist_queue_provider.dart +++ b/lib/provider/playlist_queue_provider.dart @@ -233,16 +233,38 @@ class PlaylistQueueNotifier extends PersistedStateNotifier { : []; // modifiers - void add(Track track) { - state = state?.copyWith( - tracks: state!.tracks..add(track), - ); + void add(List tracks) { + if (!isLoaded) { + loadAndPlay(tracks); + } else { + state = state?.copyWith( + tracks: state!.tracks..addAll(tracks), + ); + } } - void remove(Track track) { + void remove(List tracks) { + if (!isLoaded) return; + final trackIds = tracks.map((e) => e.id!).toSet(); + final newTracks = state!.tracks.whereNot( + (element) => trackIds.contains(element.id), + ); + + if (newTracks.isEmpty) { + stop(); + return; + } state = state?.copyWith( - tracks: state!.tracks..remove(track), + tracks: newTracks.toSet(), + active: !newTracks.contains(state!.activeTrack) + ? state!.active > newTracks.length - 1 + ? newTracks.length - 1 + : state!.active + : null, ); + if (state!.isLoading) { + play(); + } } void shuffle() { @@ -453,6 +475,16 @@ class PlaylistQueueNotifier extends PersistedStateNotifier { .every((track) => trackIds.contains(track.id!)); } + bool isTrackOnQueue(TrackSimple track) { + if (!isLoaded) return false; + if (state!.isShuffled) { + final trackIds = state!.tempTracks.map((track) => track.id!); + return trackIds.contains(track.id!); + } + final trackIds = state!.tracks.map((track) => track.id!); + return trackIds.contains(track.id!); + } + @override Future? fromJson(Map json) { if (json.isEmpty) return null;