feat: optimize track options and related artists

This commit is contained in:
Kingkor Roy Tirtho 2025-07-23 17:34:05 +06:00
parent 3b21b05fdc
commit 3a5ddd6214
7 changed files with 700 additions and 456 deletions

View File

@ -1,4 +1,5 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
@ -80,9 +81,12 @@ class PresentationListSection extends HookConsumerWidget {
), ),
), ),
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) => HookBuilder(builder: (context) {
final track = state.presentationTracks[index]; final track = state.presentationTracks[index];
final isSelected = state.selectedTracks.any((e) => e.id == track.id); final isSelected = useMemoized(
() => state.selectedTracks.any((e) => e.id == track.id),
[track.id, state.selectedTracks],
);
return TrackTile( return TrackTile(
userPlaylist: isUserPlaylist, userPlaylist: isUserPlaylist,
playlistId: options.collectionId, playlistId: options.collectionId,
@ -105,7 +109,7 @@ class PresentationListSection extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
); );
}, }),
); );
} }
} }

View File

@ -1,7 +1,3 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.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';
@ -9,59 +5,22 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/dialogs/track_details_dialog.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/links/artist_link.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/track_options/track_options_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/user.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
album,
share,
songlink,
addToPlaylist,
addToQueue,
removeFromPlaylist,
removeFromQueue,
blacklist,
delete,
playNext,
favorite,
details,
download,
startRadio,
}
/// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject] /// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject]
class TrackOptions extends HookConsumerWidget { class TrackOptions extends HookConsumerWidget {
final SpotubeTrackObject track; final SpotubeTrackObject track;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef;
final Widget? icon; final Widget? icon;
const TrackOptions({ const TrackOptions({
super.key, super.key,
required this.track, required this.track,
this.showMenuCbRef,
this.userPlaylist = false, this.userPlaylist = false,
this.playlistId, this.playlistId,
this.icon, this.icon,
@ -70,302 +29,53 @@ class TrackOptions extends HookConsumerWidget {
"Track must be a SpotubeFullTrackObject, SpotubeLocalTrackObject", "Track must be a SpotubeFullTrackObject, SpotubeLocalTrackObject",
); );
void actionShare(BuildContext context, SpotubeTrackObject track) {
Clipboard.setData(ClipboardData(text: track.externalUri)).then((_) {
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.copied_to_clipboard(track.externalUri),
textAlign: TextAlign.center,
),
);
},
);
}
});
}
void actionAddToPlaylist(
BuildContext context,
SpotubeTrackObject track,
) {
/// showDialog doesn't work for some reason. So we have to
/// manually push a Dialog Route in the Navigator to get it working
showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
);
},
);
}
void actionStartRadio(
BuildContext context,
WidgetRef ref,
SpotubeTrackObject track,
) async {
final playback = ref.read(audioPlayerProvider.notifier);
final playlist = ref.read(audioPlayerProvider);
final metadataPlugin = await ref.read(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin(
"No default metadata plugin set",
);
}
final tracks = await metadataPlugin.track.radio(track.id);
bool replaceQueue = false;
if (context.mounted && playlist.tracks.isNotEmpty) {
replaceQueue = await showPromptDialog(
context: context,
title: context.l10n.how_to_start_radio,
message: context.l10n.replace_queue_question,
okText: context.l10n.replace,
cancelText: context.l10n.add_to_queue,
);
}
if (replaceQueue || playlist.tracks.isEmpty) {
await playback.stop();
await playback.load([track], autoPlay: true);
// we don't have to add those tracks as useEndlessPlayback will do it for us
return;
} else {
await playback.addTrack(track);
}
await playback.addTracks(
tracks.toList()
..removeWhere((e) {
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
return e.id == track.id || isDuplicate;
}),
);
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final playlist = ref.watch(audioPlayerProvider); final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
final playback = ref.watch(audioPlayerProvider.notifier); final (
final authenticated = ref.watch(metadataPluginAuthenticatedProvider); :isBlacklisted,
ref.watch(downloadManagerProvider); :isInDownloadQueue,
final downloadManager = ref.watch(downloadManagerProvider.notifier); :isInQueue,
final blacklist = ref.watch(blacklistProvider); :isActiveTrack,
final me = ref.watch(metadataPluginUserProvider); :isAuthenticated,
:isLiked,
final favorites = useTrackToggleLike(track, ref); :progressNotifier
) = ref.watch(trackOptionsStateProvider(track));
final isBlackListed = useMemoized(
() => blacklist.asData?.value.any(
(element) => element.elementId == track.id,
),
[blacklist, track],
);
final removingTrack = useState<String?>(null);
final favoritePlaylistsNotifier =
ref.watch(metadataPluginSavedPlaylistsProvider.notifier);
final isInDownloadQueue = useMemoized(() {
if (playlist.activeTrack == null ||
playlist.activeTrack! is SpotubeLocalTrackObject) {
return false;
}
return downloadManager.isActive(
playlist.activeTrack! as SpotubeFullTrackObject,
);
}, [
playlist.activeTrack,
downloadManager,
]);
final progressNotifier = useMemoized(() {
if (track is SpotubeLocalTrackObject) {
return null;
}
return downloadManager
.getProgressNotifier(track as SpotubeFullTrackObject);
}, [downloadManager, track]);
final isLocalTrack = track is SpotubeLocalTrackObject; final isLocalTrack = track is SpotubeLocalTrackObject;
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>( return Column(
tooltip: context.l10n.more_actions, mainAxisSize: MainAxisSize.min,
onSelected: (value) async { crossAxisAlignment: CrossAxisAlignment.start,
switch (value) { spacing: 8,
case TrackOptionValue.album: children: [
await context.navigateTo(
AlbumRoute(id: track.album.id, album: track.album),
);
break;
case TrackOptionValue.delete:
await File((track as SpotubeLocalTrackObject).path).delete();
ref.invalidate(localTracksProvider);
break;
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.added_track_to_queue(track.name),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.track_will_play_next(track.name),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.removed_track_from_queue(
track.name,
),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.favorite:
favorites.toggleTrackLike(track);
break;
case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, track);
break;
case TrackOptionValue.removeFromPlaylist:
removingTrack.value = track.externalUri;
favoritePlaylistsNotifier
.removeTracks(playlistId ?? "", [track.id]);
break;
case TrackOptionValue.blacklist:
if (isBlackListed == null) break;
if (isBlackListed == true) {
await ref.read(blacklistProvider.notifier).remove(track.id);
} else {
await ref.read(blacklistProvider.notifier).add(
BlacklistTableCompanion.insert(
name: track.name,
elementId: track.id,
elementType: BlacklistedType.track,
),
);
}
break;
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details:
if (track is! SpotubeFullTrackObject) break;
showDialog(
context: context,
builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child:
TrackDetailsDialog(track: track as SpotubeFullTrackObject),
),
);
break;
case TrackOptionValue.download:
if (track is! SpotubeFullTrackObject) break;
await downloadManager.addToQueue(track as SpotubeFullTrackObject);
break;
case TrackOptionValue.startRadio:
actionStartRadio(context, ref, track);
break;
}
},
icon: icon ?? const Icon(SpotubeIcons.moreHorizontal),
variance: ButtonVariance.ghost,
headings: [
Basic(
leading: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: track.album.images
.asUrlString(placeholder: ImagePlaceholder.albumArt),
fit: BoxFit.cover,
),
),
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).semiBold(),
subtitle: Align(
alignment: Alignment.centerLeft,
child: ArtistLink(
artists: track.artists,
onOverflowArtistClick: () => context.navigateTo(
TrackRoute(trackId: track.id),
),
),
),
),
],
items: (context) => [
if (isLocalTrack) if (isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.delete, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.delete,
playlistId,
);
},
leading: const Icon(SpotubeIcons.trash), leading: const Icon(SpotubeIcons.trash),
child: Text(context.l10n.delete), title: Text(context.l10n.delete),
), ),
if (mediaQuery.smAndDown && !isLocalTrack) if (mediaQuery.smAndDown && !isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.album, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.album,
playlistId,
);
},
leading: const Icon(SpotubeIcons.album), leading: const Icon(SpotubeIcons.album),
child: Column( title: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -377,62 +87,116 @@ class TrackOptions extends HookConsumerWidget {
], ],
), ),
), ),
if (!playlist.containsTrack(track)) ...[ if (!isInQueue) ...[
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.addToQueue, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.addToQueue,
playlistId,
);
},
leading: const Icon(SpotubeIcons.queueAdd), leading: const Icon(SpotubeIcons.queueAdd),
child: Text(context.l10n.add_to_queue), title: Text(context.l10n.add_to_queue),
), ),
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.playNext, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.playNext,
playlistId,
);
},
leading: const Icon(SpotubeIcons.lightning), leading: const Icon(SpotubeIcons.lightning),
child: Text(context.l10n.play_next), title: Text(context.l10n.play_next),
), ),
] else ] else
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.removeFromQueue, style: ButtonVariance.menu,
enabled: playlist.activeTrack?.id != track.id, onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.removeFromQueue,
playlistId,
);
},
enabled: !isActiveTrack,
leading: const Icon(SpotubeIcons.queueRemove), leading: const Icon(SpotubeIcons.queueRemove),
child: Text(context.l10n.remove_from_queue), title: Text(context.l10n.remove_from_queue),
), ),
if (me.asData?.value != null && !isLocalTrack) if (isAuthenticated && !isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.favorite, style: ButtonVariance.menu,
leading: favorites.isLiked onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.favorite,
playlistId,
);
},
leading: isLiked
? const Icon( ? const Icon(
SpotubeIcons.heartFilled, SpotubeIcons.heartFilled,
color: Colors.pink, color: Colors.pink,
) )
: const Icon(SpotubeIcons.heart), : const Icon(SpotubeIcons.heart),
child: Text( title: Text(
favorites.isLiked isLiked
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
), ),
), ),
if (authenticated.asData?.value == true && !isLocalTrack) ...[ if (isAuthenticated && !isLocalTrack) ...[
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.startRadio, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.startRadio,
playlistId,
);
},
leading: const Icon(SpotubeIcons.radio), leading: const Icon(SpotubeIcons.radio),
child: Text(context.l10n.start_a_radio), title: Text(context.l10n.start_a_radio),
), ),
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.addToPlaylist, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.addToPlaylist,
playlistId,
);
},
leading: const Icon(SpotubeIcons.playlistAdd), leading: const Icon(SpotubeIcons.playlistAdd),
child: Text(context.l10n.add_to_playlist), title: Text(context.l10n.add_to_playlist),
), ),
], ],
if (userPlaylist && if (userPlaylist && isAuthenticated && !isLocalTrack)
authenticated.asData?.value == true && ButtonTile(
!isLocalTrack) style: ButtonVariance.menu,
AdaptiveMenuButton( onPressed: () async {
value: TrackOptionValue.removeFromPlaylist, await trackOptionActions.action(
context,
TrackOptionValue.removeFromPlaylist,
playlistId,
);
},
leading: const Icon(SpotubeIcons.removeFilled), leading: const Icon(SpotubeIcons.removeFilled),
child: Text(context.l10n.remove_from_playlist), title: Text(context.l10n.remove_from_playlist),
), ),
if (!isLocalTrack) if (!isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.download, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.download,
playlistId,
);
},
enabled: !isInDownloadQueue, enabled: !isInDownloadQueue,
leading: isInDownloadQueue leading: isInDownloadQueue
? HookBuilder(builder: (context) { ? HookBuilder(builder: (context) {
@ -442,58 +206,75 @@ class TrackOptions extends HookConsumerWidget {
); );
}) })
: const Icon(SpotubeIcons.download), : const Icon(SpotubeIcons.download),
child: Text(context.l10n.download_track), title: Text(context.l10n.download_track),
), ),
if (!isLocalTrack) if (!isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.blacklist, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.blacklist,
playlistId,
);
},
leading: Icon( leading: Icon(
SpotubeIcons.playlistRemove, SpotubeIcons.playlistRemove,
color: isBlackListed != true ? Colors.red[400] : null, color: isBlacklisted != true ? Colors.red[400] : null,
), ),
child: Text( title: Text(
isBlackListed == true isBlacklisted == true
? context.l10n.remove_from_blacklist ? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist, : context.l10n.add_to_blacklist,
style: TextStyle( style: TextStyle(
color: isBlackListed != true ? Colors.red[400] : null, color: isBlacklisted != true ? Colors.red[400] : null,
), ),
), ),
), ),
if (!isLocalTrack) if (!isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.share, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.share,
playlistId,
);
},
leading: const Icon(SpotubeIcons.share), leading: const Icon(SpotubeIcons.share),
child: Text(context.l10n.share), title: Text(context.l10n.share),
), ),
if (!isLocalTrack) if (!isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.songlink, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.songlink,
playlistId,
);
},
leading: Assets.logos.songlinkTransparent.image( leading: Assets.logos.songlinkTransparent.image(
width: 22, width: 22,
height: 22, height: 22,
color: colorScheme.foreground.withValues(alpha: 0.5), color: colorScheme.foreground.withValues(alpha: 0.5),
), ),
child: Text(context.l10n.song_link), title: Text(context.l10n.song_link),
), ),
if (!isLocalTrack) if (!isLocalTrack)
AdaptiveMenuButton( ButtonTile(
value: TrackOptionValue.details, style: ButtonVariance.menu,
onPressed: () async {
await trackOptionActions.action(
context,
TrackOptionValue.details,
playlistId,
);
},
leading: const Icon(SpotubeIcons.info), leading: const Icon(SpotubeIcons.info),
child: Text(context.l10n.details), title: Text(context.l10n.details),
), ),
], ],
); );
//! This is the most ANTI pattern I've ever done, but it works
showMenuCbRef?.value = (relativeRect) {
final offsetFromRect = Offset(
relativeRect.left,
relativeRect.top,
);
adaptivePopSheetList.showDropdownMenu(context, offsetFromRect);
};
return adaptivePopSheetList;
} }
} }

View File

@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -23,6 +24,17 @@ import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
final isBlacklistedProvider =
Provider.autoDispose.family<bool, SpotubeTrackObject>(
(ref, track) {
ref.watch(blacklistProvider);
final blacklist = ref.read(blacklistProvider.notifier);
return blacklist.contains(track);
},
);
final _overlay = ValueNotifier<OverlayCompleter<dynamic>?>(null);
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
/// [index] will not be shown if null /// [index] will not be shown if null
final int? index; final int? index;
@ -51,19 +63,35 @@ class TrackTile extends HookConsumerWidget {
this.leadingActions, this.leadingActions,
}); });
OverlayCompleter<dynamic> showOptions(
BuildContext context,
Offset offset,
) {
return showPopover(
context: context,
position: offset,
alignment: Alignment.bottomRight,
builder: (context) {
return SizedBox(
width: 220 * context.theme.scaling,
child: Card(
padding: const EdgeInsets.all(8),
child: TrackOptions(
track: track,
playlistId: playlistId,
userPlaylist: userPlaylist,
),
),
);
},
);
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final blacklist = ref.watch(blacklistProvider); final isBlackListed = ref.watch(isBlacklistedProvider(track));
final blacklistNotifier = ref.watch(blacklistProvider.notifier);
final isBlackListed = useMemoized(
() => blacklistNotifier.contains(track),
[blacklist, track],
);
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
final isLoading = useState(false); final isLoading = useState(false);
@ -82,13 +110,13 @@ class TrackTile extends HookConsumerWidget {
return Listener( return Listener(
onPointerDown: (event) { onPointerDown: (event) {
if (event.buttons != kSecondaryMouseButton) return; if (event.buttons != kSecondaryMouseButton) return;
showOptionCbRef.value?.call( if (_overlay.value != null) {
RelativeRect.fromLTRB( _overlay.value?.remove();
event.position.dx, _overlay.value = null;
event.position.dy, }
constrains.maxWidth - event.position.dx, _overlay.value = showOptions(
constrains.maxHeight - event.position.dy, context,
), Offset.zero,
); );
}, },
child: HoverBuilder( child: HoverBuilder(
@ -303,11 +331,91 @@ class TrackTile extends HookConsumerWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
TrackOptions( Builder(
track: track, builder: (context) {
playlistId: playlistId, return IconButton.ghost(
userPlaylist: userPlaylist, icon: const Icon(SpotubeIcons.moreHorizontal),
showMenuCbRef: showOptionCbRef, onPressed: () {
final mediaQuery = MediaQuery.sizeOf(context);
if (mediaQuery.lgAndUp) {
final renderBox =
context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect(
Rect.fromPoints(
renderBox.localToGlobal(Offset.zero,
ancestor: context.findRenderObject()),
renderBox.localToGlobal(
renderBox.size.bottomRight(Offset.zero),
ancestor: context.findRenderObject()),
),
Offset.zero & mediaQuery,
);
final offset = Offset(position.left, position.top);
showOptions(context, offset);
} else {
openDrawer(
context: context,
position: OverlayPosition.bottom,
draggable: true,
showDragHandle: true,
borderRadius: context.theme.borderRadiusMd,
transformBackdrop: false,
builder: (context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Basic(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius:
context.theme.borderRadiusMd,
image: DecorationImage(
fit: BoxFit.cover,
image: imageProvider,
),
),
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).semiBold(),
subtitle: Align(
alignment: Alignment.centerLeft,
child: ArtistLink(
artists: track.artists,
onOverflowArtistClick: () =>
context.navigateTo(
TrackRoute(trackId: track.id),
),
),
),
),
const Divider(),
TrackOptions(
track: track,
userPlaylist: userPlaylist,
playlistId: playlistId,
),
],
),
);
},
);
}
},
);
},
), ),
if (kIsDesktop) const Gap(10), if (kIsDesktop) const Gap(10),
], ],

View File

@ -2,19 +2,22 @@ import 'package:flutter/material.dart' as material;
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:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/artist/artist_album_list.dart'; import 'package:spotube/modules/artist/artist_album_list.dart';
import 'package:spotube/pages/artist/section/footer.dart'; import 'package:spotube/pages/artist/section/footer.dart';
import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/header.dart';
// import 'package:spotube/pages/artist/section/related_artists.dart'; import 'package:spotube/pages/artist/section/related_artists.dart';
import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart';
import 'package:spotube/provider/metadata_plugin/artist/albums.dart'; import 'package:spotube/provider/metadata_plugin/artist/albums.dart';
import 'package:spotube/provider/metadata_plugin/artist/artist.dart'; import 'package:spotube/provider/metadata_plugin/artist/artist.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:spotube/provider/metadata_plugin/artist/related.dart';
import 'package:spotube/provider/metadata_plugin/artist/top_tracks.dart'; import 'package:spotube/provider/metadata_plugin/artist/top_tracks.dart';
import 'package:spotube/provider/metadata_plugin/artist/wikipedia.dart'; import 'package:spotube/provider/metadata_plugin/artist/wikipedia.dart';
import 'package:spotube/provider/metadata_plugin/library/artists.dart'; import 'package:spotube/provider/metadata_plugin/library/artists.dart';
@ -48,7 +51,9 @@ class ArtistPage extends HookConsumerWidget {
child: material.RefreshIndicator.adaptive( child: material.RefreshIndicator.adaptive(
onRefresh: () async { onRefresh: () async {
ref.invalidate(metadataPluginArtistProvider(artistId)); ref.invalidate(metadataPluginArtistProvider(artistId));
// ref.invalidate(relatedArtistsProvider(artistId)); ref.invalidate(
metadataPluginArtistRelatedArtistsProvider(artistId),
);
ref.invalidate(metadataPluginArtistAlbumsProvider(artistId)); ref.invalidate(metadataPluginArtistAlbumsProvider(artistId));
ref.invalidate(metadataPluginIsSavedArtistProvider(artistId)); ref.invalidate(metadataPluginIsSavedArtistProvider(artistId));
ref.invalidate(metadataPluginArtistTopTracksProvider(artistId)); ref.invalidate(metadataPluginArtistTopTracksProvider(artistId));
@ -67,6 +72,7 @@ class ArtistPage extends HookConsumerWidget {
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
const SliverGap(material.kToolbarHeight),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
@ -77,16 +83,16 @@ class ArtistPage extends HookConsumerWidget {
ArtistPageTopTracks(artistId: artistId), ArtistPageTopTracks(artistId: artistId),
const SliverGap(20), const SliverGap(20),
SliverToBoxAdapter(child: ArtistAlbumList(artistId)), SliverToBoxAdapter(child: ArtistAlbumList(artistId)),
// SliverPadding( SliverPadding(
// padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
// sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
// child: Text( child: Text(
// context.l10n.fans_also_like, context.l10n.fans_also_like,
// style: theme.typography.h4, style: context.theme.typography.h4,
// ), ),
// ), ),
// ), ),
// ArtistPageRelatedArtists(artistId: artistId), ArtistPageRelatedArtists(artistId: artistId),
const SliverGap(20), const SliverGap(20),
if (artistQuery.asData?.value != null) if (artistQuery.asData?.value != null)
SliverToBoxAdapter( SliverToBoxAdapter(

View File

@ -1,7 +1,8 @@
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/provider/metadata_plugin/artist/related.dart';
@Deprecated("Related artists are no longer supported by Spotube")
class ArtistPageRelatedArtists extends ConsumerWidget { class ArtistPageRelatedArtists extends ConsumerWidget {
final String artistId; final String artistId;
const ArtistPageRelatedArtists({ const ArtistPageRelatedArtists({
@ -11,39 +12,38 @@ class ArtistPageRelatedArtists extends ConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
return const SizedBox.shrink(); final relatedArtists =
// final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); ref.watch(metadataPluginArtistRelatedArtistsProvider(artistId));
// return switch (relatedArtists) { return switch (relatedArtists) {
// AsyncData(value: final artists) => SliverPadding( AsyncData(value: final artists) => SliverPadding(
// padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
// sliver: SliverGrid.builder( sliver: SliverGrid.builder(
// itemCount: artists.length, itemCount: artists.items.length,
// gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
// maxCrossAxisExtent: 200, maxCrossAxisExtent: 200,
// mainAxisExtent: 250, mainAxisExtent: 250,
// mainAxisSpacing: 10, mainAxisSpacing: 10,
// crossAxisSpacing: 10, crossAxisSpacing: 10,
// childAspectRatio: 0.8, childAspectRatio: 0.8,
// ), ),
// itemBuilder: (context, index) { itemBuilder: (context, index) {
// final artist = artists.elementAt(index); final artist = artists.items.elementAt(index);
// return SizedBox( return SizedBox(
// width: 180, width: 180,
// // child: ArtistCard(artist), child: ArtistCard(artist),
// ); );
// // return ArtistCard(artist); },
// }, ),
// ), ),
// ), AsyncError(:final error) => SliverToBoxAdapter(
// AsyncError(:final error) => SliverToBoxAdapter( child: Center(
// child: Center( child: Text(error.toString()),
// child: Text(error.toString()), ),
// ), ),
// ), _ => const SliverToBoxAdapter(
// _ => const SliverToBoxAdapter( child: Center(child: CircularProgressIndicator()),
// child: Center(child: CircularProgressIndicator()), ),
// ), };
// };
} }
} }

View File

@ -0,0 +1,32 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/utils/family_paginated.dart';
class MetadataPluginArtistRelatedArtistsNotifier
extends FamilyPaginatedAsyncNotifier<SpotubeFullArtistObject, String> {
@override
Future<SpotubePaginationResponseObject<SpotubeFullArtistObject>> fetch(
int offset,
int limit,
) async {
return await (await metadataPlugin).artist.related(
arg,
limit: limit,
offset: offset,
);
}
@override
build(arg) async {
ref.watch(metadataPluginProvider);
return await fetch(0, 20);
}
}
final metadataPluginArtistRelatedArtistsProvider = AsyncNotifierProviderFamily<
MetadataPluginArtistRelatedArtistsNotifier,
SpotubePaginationResponseObject<SpotubeFullArtistObject>,
String>(
() => MetadataPluginArtistRelatedArtistsNotifier(),
);

View File

@ -0,0 +1,313 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/dialogs/track_details_dialog.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.dart';
import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/services/metadata/endpoints/error.dart';
import 'package:url_launcher/url_launcher_string.dart';
enum TrackOptionValue {
album,
share,
songlink,
addToPlaylist,
addToQueue,
removeFromPlaylist,
removeFromQueue,
blacklist,
delete,
playNext,
favorite,
details,
download,
startRadio,
}
class TrackOptionsActions {
final Ref ref;
final SpotubeTrackObject track;
TrackOptionsActions(this.ref, this.track);
AudioPlayerNotifier get playback => ref.read(audioPlayerProvider.notifier);
MetadataPluginSavedTracksNotifier get favoriteTracks =>
ref.read(metadataPluginSavedTracksProvider.notifier);
MetadataPluginSavedPlaylistsNotifier get favoritePlaylistsNotifier =>
ref.read(metadataPluginSavedPlaylistsProvider.notifier);
DownloadManagerProvider get downloadManager =>
ref.read(downloadManagerProvider.notifier);
BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier);
void actionShare(BuildContext context) {
Clipboard.setData(ClipboardData(text: track.externalUri)).then((_) {
if (context.mounted) {
showToast(
context: rootNavigatorKey.currentContext!,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.copied_to_clipboard(track.externalUri),
textAlign: TextAlign.center,
),
);
},
);
}
});
}
Future<void> actionAddToPlaylist(
BuildContext context,
String? playlistId,
) async {
/// showDialog doesn't work for some reason. So we have to
/// manually push a Dialog Route in the Navigator to get it working
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: [track],
openFromPlaylist: playlistId,
);
},
);
}
Future<void> actionStartRadio(BuildContext context) async {
final playback = ref.read(audioPlayerProvider.notifier);
final playlist = ref.read(audioPlayerProvider);
final metadataPlugin = await ref.read(metadataPluginProvider.future);
if (metadataPlugin == null) {
throw MetadataPluginException.noDefaultPlugin(
"No default metadata plugin set",
);
}
final tracks = await metadataPlugin.track.radio(track.id);
bool replaceQueue = false;
if (context.mounted && playlist.tracks.isNotEmpty) {
replaceQueue = await showPromptDialog(
context: context,
title: context.l10n.how_to_start_radio,
message: context.l10n.replace_queue_question,
okText: context.l10n.replace,
cancelText: context.l10n.add_to_queue,
);
}
if (replaceQueue || playlist.tracks.isEmpty) {
await playback.stop();
await playback.load([track], autoPlay: true);
// we don't have to add those tracks as useEndlessPlayback will do it for us
return;
} else {
await playback.addTrack(track);
}
await playback.addTracks(
tracks.toList()
..removeWhere((e) {
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
return e.id == track.id || isDuplicate;
}),
);
}
Future<void> action(
BuildContext context,
TrackOptionValue value,
String? playlistId,
) async {
switch (value) {
case TrackOptionValue.album:
await context.navigateTo(
AlbumRoute(id: track.album.id, album: track.album),
);
break;
case TrackOptionValue.delete:
await File((track as SpotubeLocalTrackObject).path).delete();
ref.invalidate(localTracksProvider);
break;
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.added_track_to_queue(track.name),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.track_will_play_next(track.name),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id);
if (context.mounted) {
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n.removed_track_from_queue(
track.name,
),
textAlign: TextAlign.center,
),
);
},
);
}
break;
case TrackOptionValue.favorite:
final isLikedTrack = await ref.read(
metadataPluginIsSavedTrackProvider(track.id).future,
);
if (isLikedTrack) {
await favoriteTracks.removeFavorite([track]);
} else {
await favoriteTracks.addFavorite([track]);
}
break;
case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, playlistId);
break;
case TrackOptionValue.removeFromPlaylist:
favoritePlaylistsNotifier.removeTracks(playlistId ?? "", [track.id]);
break;
case TrackOptionValue.blacklist:
final isBlacklisted = blacklist.contains(track);
if (isBlacklisted == true) {
await ref.read(blacklistProvider.notifier).remove(track.id);
} else {
await ref.read(blacklistProvider.notifier).add(
BlacklistTableCompanion.insert(
name: track.name,
elementId: track.id,
elementType: BlacklistedType.track,
),
);
}
break;
case TrackOptionValue.share:
actionShare(context);
break;
case TrackOptionValue.songlink:
final url = "https://song.link/s/${track.id}";
await launchUrlString(url);
break;
case TrackOptionValue.details:
if (track is! SpotubeFullTrackObject) break;
showDialog(
context: context,
builder: (context) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: TrackDetailsDialog(track: track as SpotubeFullTrackObject),
),
);
break;
case TrackOptionValue.download:
if (track is! SpotubeFullTrackObject) break;
await downloadManager.addToQueue(track as SpotubeFullTrackObject);
break;
case TrackOptionValue.startRadio:
actionStartRadio(context);
break;
}
}
}
typedef TrackOptionFlags = ({
bool isInQueue,
bool isBlacklisted,
bool isInDownloadQueue,
bool isActiveTrack,
bool isAuthenticated,
bool isLiked,
ValueNotifier<double>? progressNotifier,
});
final trackOptionActionsProvider =
Provider.family<TrackOptionsActions, SpotubeTrackObject>(
(ref, track) => TrackOptionsActions(ref, track),
);
final trackOptionsStateProvider =
Provider.family<TrackOptionFlags, SpotubeTrackObject>((ref, track) {
ref.watch(downloadManagerProvider);
ref.watch(blacklistProvider);
final playlist = ref.watch(audioPlayerProvider);
final authenticated = ref.watch(metadataPluginAuthenticatedProvider);
final downloadManager = ref.watch(downloadManagerProvider.notifier);
final blacklist = ref.watch(blacklistProvider.notifier);
final isBlacklisted = blacklist.contains(track);
final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id));
final isInDownloadQueue = playlist.activeTrack == null ||
playlist.activeTrack! is SpotubeLocalTrackObject
? false
: downloadManager
.isActive(playlist.activeTrack! as SpotubeFullTrackObject);
final progressNotifier = track is SpotubeLocalTrackObject
? null
: downloadManager.getProgressNotifier(track as SpotubeFullTrackObject);
return (
isInQueue: playlist.containsTrack(track),
isBlacklisted: isBlacklisted,
isInDownloadQueue: isInDownloadQueue,
isActiveTrack: playlist.activeTrack?.id == track.id,
isAuthenticated: authenticated.asData?.value ?? false,
isLiked: isSavedTrack.asData?.value ?? false,
progressNotifier: progressNotifier,
);
});