feat: add or remove track, playlist or album to queue support

This commit is contained in:
Kingkor Roy Tirtho 2023-02-05 15:14:35 +06:00
parent 9bad8c9eb8
commit b8f3493138
10 changed files with 230 additions and 46 deletions

View File

@ -25,6 +25,8 @@ abstract class SpotubeIcons {
static const heart = FeatherIcons.heart; static const heart = FeatherIcons.heart;
static const heartFilled = Icons.favorite_rounded; static const heartFilled = Icons.favorite_rounded;
static const queue = Icons.queue_music_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 download = FeatherIcons.download;
static const done = FeatherIcons.checkCircle; static const done = FeatherIcons.checkCircle;
static const alternativeRoute = Icons.alt_route_rounded; static const alternativeRoute = Icons.alt_route_rounded;

View File

@ -1,13 +1,16 @@
import 'package:fl_query/fl_query.dart';
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:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.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/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/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:uuid/uuid.dart';
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final Album album; final Album album;
@ -23,39 +26,66 @@ class AlbumCard extends HookConsumerWidget {
final playlist = ref.watch(PlaylistQueueNotifier.provider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false; final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier); final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(album.tracks!); final queryBowl = QueryBowl.of(context);
final query = queryBowl.getQuery<List<TrackSimple>, SpotifyApi>(
Queries.album.tracksOf(album.id!).queryKey);
final tracks = useState(query?.data ?? album.tracks ?? <Track>[]);
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks.value);
final int marginH = final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard( return PlaybuttonCard(
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: TypeConversionUtils.image_X_UrlString(
album.images, album.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
viewType: viewType, viewType: viewType,
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: isPlaylistPlaying && playing, isPlaying: isPlaylistPlaying && playing,
isLoading: isPlaylistPlaying && playlist?.isLoading == true, isLoading: isPlaylistPlaying && playlist?.isLoading == true,
title: album.name!, title: album.name!,
description: description:
"Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}", "Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
onTap: () { onTap: () {
ServiceUtils.navigate(context, "/album/${album.id}", extra: album); ServiceUtils.navigate(context, "/album/${album.id}", extra: album);
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
SpotifyApi spotify = ref.read(spotifyProvider); if (isPlaylistPlaying && playing) {
if (isPlaylistPlaying && playing) { return playlistNotifier.pause();
return playlistNotifier.pause(); } else if (isPlaylistPlaying && !playing) {
} else if (isPlaylistPlaying && !playing) { return playlistNotifier.resume();
return playlistNotifier.resume(); }
}
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList();
if (tracks.isEmpty) return;
await playlistNotifier.loadAndPlay(tracks); await playlistNotifier.loadAndPlay(album.tracks
}, ?.map(
); (e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList() ??
[]);
},
onAddToQueuePressed: () async {
if (isPlaylistPlaying) {
return;
}
final fetchedTracks =
await queryBowl.fetchQuery<List<TrackSimple>, SpotifyApi>(
Queries.album.tracksOf(album.id!),
externalData: ref.read(spotifyProvider),
key: ValueKey(const Uuid().v4()),
);
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
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"),
),
);
});
} }
} }

View File

@ -70,6 +70,25 @@ class PlaylistCard extends HookConsumerWidget {
await playlistNotifier.loadAndPlay(fetchedTracks); await playlistNotifier.loadAndPlay(fetchedTracks);
tracks.value = fetchedTracks; tracks.value = fetchedTracks;
}, },
onAddToQueuePressed: () async {
if (isPlaylistPlaying) return;
List<Track> 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"),
),
);
},
); );
} }
} }

View File

@ -13,6 +13,7 @@ enum PlaybuttonCardViewType { square, list }
class PlaybuttonCard extends HookWidget { class PlaybuttonCard extends HookWidget {
final void Function()? onTap; final void Function()? onTap;
final void Function()? onPlaybuttonPressed; final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description; final String? description;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final String imageUrl; final String imageUrl;
@ -29,6 +30,7 @@ class PlaybuttonCard extends HookWidget {
this.margin, this.margin,
this.description, this.description,
this.onPlaybuttonPressed, this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap, this.onTap,
this.viewType = PlaybuttonCardViewType.square, this.viewType = PlaybuttonCardViewType.square,
Key? key, Key? key,
@ -101,6 +103,15 @@ class PlaybuttonCard extends HookWidget {
color: Colors.white, 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( final image = Padding(
padding: EdgeInsets.all( padding: EdgeInsets.all(
platform == TargetPlatform.windows ? 5 : 0, platform == TargetPlatform.windows ? 5 : 0,
@ -131,7 +142,16 @@ class PlaybuttonCard extends HookWidget {
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
bottom: 10, bottom: 10,
end: 5, 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(), const Spacer(),
addToQueueButton,
const SizedBox(width: 10),
playButton, playButton,
const SizedBox(width: 10), const SizedBox(width: 10),
], ],

View File

@ -31,6 +31,7 @@ class TrackCollectionView<T> extends HookConsumerWidget {
final String titleImage; final String titleImage;
final bool isPlaying; final bool isPlaying;
final void Function([Track? currentTrack]) onPlay; final void Function([Track? currentTrack]) onPlay;
final void Function() onAddToQueue;
final void Function([Track? currentTrack]) onShuffledPlay; final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onShare; final void Function() onShare;
final Widget? heartBtn; final Widget? heartBtn;
@ -49,6 +50,7 @@ class TrackCollectionView<T> extends HookConsumerWidget {
required this.isPlaying, required this.isPlaying,
required this.onPlay, required this.onPlay,
required this.onShuffledPlay, required this.onShuffledPlay,
required this.onAddToQueue,
required this.onShare, required this.onShare,
required this.routePath, required this.routePath,
this.heartBtn, this.heartBtn,
@ -87,14 +89,20 @@ class TrackCollectionView<T> extends HookConsumerWidget {
onPressed: onShuffledPlay, onPressed: onShuffledPlay,
), ),
const SizedBox(width: 5), 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 // play playlist
PlatformIconButton( PlatformIconButton(
backgroundColor: PlatformTheme.of(context).primaryColor, backgroundColor: PlatformTheme.of(context).primaryColor,
onPressed: tracksSnapshot.data != null ? onPlay : null, onPressed: tracksSnapshot.data != null ? onPlay : null,
icon: Icon( icon: Icon(isPlaying ? SpotubeIcons.stop : SpotubeIcons.play),
isPlaying ? SpotubeIcons.stop : SpotubeIcons.play,
color: PlatformTextTheme.of(context).body?.color,
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
]; ];

View File

@ -74,6 +74,8 @@ class TrackTile extends HookConsumerWidget {
); );
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final playlistQueueNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final removingTrack = useState<String?>(null); final removingTrack = useState<String?>(null);
final removeTrack = useMutation<bool, Tuple2<SpotifyApi, String>>( final removeTrack = useMutation<bool, Tuple2<SpotifyApi, String>>(
job: Mutations.playlist.removeTrackOf(playlistId ?? ""), job: Mutations.playlist.removeTrackOf(playlistId ?? ""),
@ -319,6 +321,34 @@ class TrackTile extends HookConsumerWidget {
if (!isLocal) if (!isLocal)
AdaptiveActions( AdaptiveActions(
actions: [ 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) if (toggler.item3.hasData)
Action( Action(
icon: toggler.item1 icon: toggler.item1
@ -334,7 +364,7 @@ class TrackTile extends HookConsumerWidget {
), ),
if (auth.isLoggedIn) if (auth.isLoggedIn)
Action( Action(
icon: const Icon(SpotubeIcons.addFilled), icon: const Icon(SpotubeIcons.playlistAdd),
text: const PlatformText("Add To playlist"), text: const PlatformText("Add To playlist"),
onPressed: actionAddToPlaylist, onPressed: actionAddToPlaylist,
), ),

View File

@ -98,10 +98,25 @@ class AlbumPage extends HookConsumerWidget {
ref, ref,
); );
} else { } 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: () { onShare: () {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: "https://open.spotify.com/album/${album.id}"), ClipboardData(text: "https://open.spotify.com/album/${album.id}"),

View File

@ -323,6 +323,27 @@ class ArtistPage extends HookConsumerWidget {
style: style:
PlatformTheme.of(context).textTheme?.headline, 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( PlatformIconButton(
icon: Icon( icon: Icon(
isPlaylistPlaying isPlaylistPlaying

View File

@ -92,10 +92,15 @@ class PlaylistView extends HookConsumerWidget {
currentTrack: track, currentTrack: track,
); );
} else { } else {
playlistNotifier.stop(); playlistNotifier.remove(tracksSnapshot.data!);
} }
} }
}, },
onAddToQueue: () {
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
playlistNotifier.add(tracksSnapshot.data!);
}
},
bottomSpace: breakpoint.isLessThanOrEqualTo(Breakpoints.md), bottomSpace: breakpoint.isLessThanOrEqualTo(Breakpoints.md),
showShare: playlist.id != "user-liked-tracks", showShare: playlist.id != "user-liked-tracks",
routePath: "/playlist/${playlist.id}", routePath: "/playlist/${playlist.id}",

View File

@ -233,16 +233,38 @@ class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
: []; : [];
// modifiers // modifiers
void add(Track track) { void add(List<Track> tracks) {
state = state?.copyWith( if (!isLoaded) {
tracks: state!.tracks..add(track), loadAndPlay(tracks);
); } else {
state = state?.copyWith(
tracks: state!.tracks..addAll(tracks),
);
}
} }
void remove(Track track) { void remove(List<Track> tracks) {
state = state?.copyWith( if (!isLoaded) return;
tracks: state!.tracks..remove(track), 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: newTracks.toSet(),
active: !newTracks.contains(state!.activeTrack)
? state!.active > newTracks.length - 1
? newTracks.length - 1
: state!.active
: null,
);
if (state!.isLoading) {
play();
}
} }
void shuffle() { void shuffle() {
@ -453,6 +475,16 @@ class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
.every((track) => trackIds.contains(track.id!)); .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 @override
Future<PlaylistQueue>? fromJson(Map<String, dynamic> json) { Future<PlaylistQueue>? fromJson(Map<String, dynamic> json) {
if (json.isEmpty) return null; if (json.isEmpty) return null;