mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-14 00:15:17 +00:00
feat: add or remove track, playlist or album to queue support
This commit is contained in:
parent
9bad8c9eb8
commit
b8f3493138
@ -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;
|
||||
|
@ -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<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 =
|
||||
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<ArtistSimple>(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<Track> 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<ArtistSimple>(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(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"),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -70,6 +70,25 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
await playlistNotifier.loadAndPlay(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"),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
],
|
||||
|
@ -31,6 +31,7 @@ class TrackCollectionView<T> 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<T> 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<T> 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),
|
||||
];
|
||||
|
@ -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<String?>(null);
|
||||
final removeTrack = useMutation<bool, Tuple2<SpotifyApi, String>>(
|
||||
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,
|
||||
),
|
||||
|
@ -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}"),
|
||||
|
@ -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
|
||||
|
@ -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}",
|
||||
|
@ -233,16 +233,38 @@ class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
|
||||
: [];
|
||||
|
||||
// modifiers
|
||||
void add(Track track) {
|
||||
state = state?.copyWith(
|
||||
tracks: state!.tracks..add(track),
|
||||
);
|
||||
void add(List<Track> tracks) {
|
||||
if (!isLoaded) {
|
||||
loadAndPlay(tracks);
|
||||
} else {
|
||||
state = state?.copyWith(
|
||||
tracks: state!.tracks..addAll(tracks),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(Track track) {
|
||||
state = state?.copyWith(
|
||||
tracks: state!.tracks..remove(track),
|
||||
void remove(List<Track> 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: 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<PlaylistQueue?> {
|
||||
.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<PlaylistQueue>? fromJson(Map<String, dynamic> json) {
|
||||
if (json.isEmpty) return null;
|
||||
|
Loading…
Reference in New Issue
Block a user