From 3a5ddd6214b7b89dd9db00569b4a8b7804a77b9c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 23 Jul 2025 17:34:05 +0600 Subject: [PATCH] feat: optimize track options and related artists --- .../track_presentation/presentation_list.dart | 10 +- lib/components/track_tile/track_options.dart | 553 ++++++------------ lib/components/track_tile/track_tile.dart | 150 ++++- lib/pages/artist/artist.dart | 30 +- lib/pages/artist/section/related_artists.dart | 68 +-- .../metadata_plugin/artist/related.dart | 32 + .../track_options/track_options_provider.dart | 313 ++++++++++ 7 files changed, 700 insertions(+), 456 deletions(-) create mode 100644 lib/provider/metadata_plugin/artist/related.dart create mode 100644 lib/provider/track_options/track_options_provider.dart diff --git a/lib/components/track_presentation/presentation_list.dart b/lib/components/track_presentation/presentation_list.dart index dda7dffa..d41416b4 100644 --- a/lib/components/track_presentation/presentation_list.dart +++ b/lib/components/track_presentation/presentation_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.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 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( userPlaylist: isUserPlaylist, playlistId: options.collectionId, @@ -105,7 +109,7 @@ class PresentationListSection extends HookConsumerWidget { HapticFeedback.selectionClick(); }, ); - }, + }), ); } } diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index f4a295f7..be8c35b6 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -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: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:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.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/components/ui/button_tile.dart'; import 'package:spotube/extensions/constrains.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/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/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, -} +import 'package:spotube/provider/track_options/track_options_provider.dart'; /// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject] class TrackOptions extends HookConsumerWidget { final SpotubeTrackObject track; final bool userPlaylist; final String? playlistId; - final ObjectRef?>? showMenuCbRef; final Widget? icon; const TrackOptions({ super.key, required this.track, - this.showMenuCbRef, this.userPlaylist = false, this.playlistId, this.icon, @@ -70,302 +29,53 @@ class TrackOptions extends HookConsumerWidget { "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 Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); final ThemeData(:colorScheme) = Theme.of(context); - final playlist = ref.watch(audioPlayerProvider); - final playback = ref.watch(audioPlayerProvider.notifier); - final authenticated = ref.watch(metadataPluginAuthenticatedProvider); - ref.watch(downloadManagerProvider); - final downloadManager = ref.watch(downloadManagerProvider.notifier); - final blacklist = ref.watch(blacklistProvider); - final me = ref.watch(metadataPluginUserProvider); - - final favorites = useTrackToggleLike(track, ref); - - final isBlackListed = useMemoized( - () => blacklist.asData?.value.any( - (element) => element.elementId == track.id, - ), - [blacklist, track], - ); - - final removingTrack = useState(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 trackOptionActions = ref.watch(trackOptionActionsProvider(track)); + final ( + :isBlacklisted, + :isInDownloadQueue, + :isInQueue, + :isActiveTrack, + :isAuthenticated, + :isLiked, + :progressNotifier + ) = ref.watch(trackOptionsStateProvider(track)); final isLocalTrack = track is SpotubeLocalTrackObject; - final adaptivePopSheetList = AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - onSelected: (value) 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: - 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) => [ + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ if (isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.delete, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.delete, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.trash), - child: Text(context.l10n.delete), + title: Text(context.l10n.delete), ), if (mediaQuery.smAndDown && !isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.album, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.album, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.album), - child: Column( + title: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -377,62 +87,116 @@ class TrackOptions extends HookConsumerWidget { ], ), ), - if (!playlist.containsTrack(track)) ...[ - AdaptiveMenuButton( - value: TrackOptionValue.addToQueue, + if (!isInQueue) ...[ + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.addToQueue, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.queueAdd), - child: Text(context.l10n.add_to_queue), + title: Text(context.l10n.add_to_queue), ), - AdaptiveMenuButton( - value: TrackOptionValue.playNext, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.playNext, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.lightning), - child: Text(context.l10n.play_next), + title: Text(context.l10n.play_next), ), ] else - AdaptiveMenuButton( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.removeFromQueue, + playlistId, + ); + }, + enabled: !isActiveTrack, 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) - AdaptiveMenuButton( - value: TrackOptionValue.favorite, - leading: favorites.isLiked + if (isAuthenticated && !isLocalTrack) + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.favorite, + playlistId, + ); + }, + leading: isLiked ? const Icon( SpotubeIcons.heartFilled, color: Colors.pink, ) : const Icon(SpotubeIcons.heart), - child: Text( - favorites.isLiked + title: Text( + isLiked ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, ), ), - if (authenticated.asData?.value == true && !isLocalTrack) ...[ - AdaptiveMenuButton( - value: TrackOptionValue.startRadio, + if (isAuthenticated && !isLocalTrack) ...[ + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.startRadio, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.radio), - child: Text(context.l10n.start_a_radio), + title: Text(context.l10n.start_a_radio), ), - AdaptiveMenuButton( - value: TrackOptionValue.addToPlaylist, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.addToPlaylist, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.playlistAdd), - child: Text(context.l10n.add_to_playlist), + title: Text(context.l10n.add_to_playlist), ), ], - if (userPlaylist && - authenticated.asData?.value == true && - !isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.removeFromPlaylist, + if (userPlaylist && isAuthenticated && !isLocalTrack) + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.removeFromPlaylist, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.removeFilled), - child: Text(context.l10n.remove_from_playlist), + title: Text(context.l10n.remove_from_playlist), ), if (!isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.download, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.download, + playlistId, + ); + }, enabled: !isInDownloadQueue, leading: isInDownloadQueue ? HookBuilder(builder: (context) { @@ -442,58 +206,75 @@ class TrackOptions extends HookConsumerWidget { ); }) : const Icon(SpotubeIcons.download), - child: Text(context.l10n.download_track), + title: Text(context.l10n.download_track), ), if (!isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.blacklist, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.blacklist, + playlistId, + ); + }, leading: Icon( SpotubeIcons.playlistRemove, - color: isBlackListed != true ? Colors.red[400] : null, + color: isBlacklisted != true ? Colors.red[400] : null, ), - child: Text( - isBlackListed == true + title: Text( + isBlacklisted == true ? context.l10n.remove_from_blacklist : context.l10n.add_to_blacklist, style: TextStyle( - color: isBlackListed != true ? Colors.red[400] : null, + color: isBlacklisted != true ? Colors.red[400] : null, ), ), ), if (!isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.share, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.share, + playlistId, + ); + }, leading: const Icon(SpotubeIcons.share), - child: Text(context.l10n.share), + title: Text(context.l10n.share), ), if (!isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.songlink, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.songlink, + playlistId, + ); + }, leading: Assets.logos.songlinkTransparent.image( width: 22, height: 22, color: colorScheme.foreground.withValues(alpha: 0.5), ), - child: Text(context.l10n.song_link), + title: Text(context.l10n.song_link), ), if (!isLocalTrack) - AdaptiveMenuButton( - value: TrackOptionValue.details, + ButtonTile( + style: ButtonVariance.menu, + onPressed: () async { + await trackOptionActions.action( + context, + TrackOptionValue.details, + playlistId, + ); + }, 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; } } diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 41def4b0..b3c387e5 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/routes.gr.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/utils/platform.dart'; +final isBlacklistedProvider = + Provider.autoDispose.family( + (ref, track) { + ref.watch(blacklistProvider); + final blacklist = ref.read(blacklistProvider.notifier); + return blacklist.contains(track); + }, +); + +final _overlay = ValueNotifier?>(null); + class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null final int? index; @@ -51,19 +63,35 @@ class TrackTile extends HookConsumerWidget { this.leadingActions, }); + OverlayCompleter 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 Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final blacklist = ref.watch(blacklistProvider); - final blacklistNotifier = ref.watch(blacklistProvider.notifier); - - final isBlackListed = useMemoized( - () => blacklistNotifier.contains(track), - [blacklist, track], - ); - - final showOptionCbRef = useRef?>(null); + final isBlackListed = ref.watch(isBlacklistedProvider(track)); final isLoading = useState(false); @@ -82,13 +110,13 @@ class TrackTile extends HookConsumerWidget { return Listener( onPointerDown: (event) { if (event.buttons != kSecondaryMouseButton) return; - showOptionCbRef.value?.call( - RelativeRect.fromLTRB( - event.position.dx, - event.position.dy, - constrains.maxWidth - event.position.dx, - constrains.maxHeight - event.position.dy, - ), + if (_overlay.value != null) { + _overlay.value?.remove(); + _overlay.value = null; + } + _overlay.value = showOptions( + context, + Offset.zero, ); }, child: HoverBuilder( @@ -303,11 +331,91 @@ class TrackTile extends HookConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - showMenuCbRef: showOptionCbRef, + Builder( + builder: (context) { + return IconButton.ghost( + icon: const Icon(SpotubeIcons.moreHorizontal), + 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), ], diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d6243f71..64bed283 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -2,19 +2,22 @@ import 'package:flutter/material.dart' as material; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/components/button/back_button.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/pages/artist/section/footer.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/provider/metadata_plugin/artist/albums.dart'; import 'package:spotube/provider/metadata_plugin/artist/artist.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/wikipedia.dart'; import 'package:spotube/provider/metadata_plugin/library/artists.dart'; @@ -48,7 +51,9 @@ class ArtistPage extends HookConsumerWidget { child: material.RefreshIndicator.adaptive( onRefresh: () async { ref.invalidate(metadataPluginArtistProvider(artistId)); - // ref.invalidate(relatedArtistsProvider(artistId)); + ref.invalidate( + metadataPluginArtistRelatedArtistsProvider(artistId), + ); ref.invalidate(metadataPluginArtistAlbumsProvider(artistId)); ref.invalidate(metadataPluginIsSavedArtistProvider(artistId)); ref.invalidate(metadataPluginArtistTopTracksProvider(artistId)); @@ -67,6 +72,7 @@ class ArtistPage extends HookConsumerWidget { child: CustomScrollView( controller: scrollController, slivers: [ + const SliverGap(material.kToolbarHeight), SliverToBoxAdapter( child: SafeArea( bottom: false, @@ -77,16 +83,16 @@ class ArtistPage extends HookConsumerWidget { ArtistPageTopTracks(artistId: artistId), const SliverGap(20), SliverToBoxAdapter(child: ArtistAlbumList(artistId)), - // SliverPadding( - // padding: const EdgeInsets.all(8.0), - // sliver: SliverToBoxAdapter( - // child: Text( - // context.l10n.fans_also_like, - // style: theme.typography.h4, - // ), - // ), - // ), - // ArtistPageRelatedArtists(artistId: artistId), + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.fans_also_like, + style: context.theme.typography.h4, + ), + ), + ), + ArtistPageRelatedArtists(artistId: artistId), const SliverGap(20), if (artistQuery.asData?.value != null) SliverToBoxAdapter( diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 050c8a5c..ec17e240 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,7 +1,8 @@ import 'package:shadcn_flutter/shadcn_flutter.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 { final String artistId; const ArtistPageRelatedArtists({ @@ -11,39 +12,38 @@ class ArtistPageRelatedArtists extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - return const SizedBox.shrink(); - // final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); + final relatedArtists = + ref.watch(metadataPluginArtistRelatedArtistsProvider(artistId)); - // return switch (relatedArtists) { - // AsyncData(value: final artists) => SliverPadding( - // padding: const EdgeInsets.symmetric(horizontal: 8.0), - // sliver: SliverGrid.builder( - // itemCount: artists.length, - // gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - // maxCrossAxisExtent: 200, - // mainAxisExtent: 250, - // mainAxisSpacing: 10, - // crossAxisSpacing: 10, - // childAspectRatio: 0.8, - // ), - // itemBuilder: (context, index) { - // final artist = artists.elementAt(index); - // return SizedBox( - // width: 180, - // // child: ArtistCard(artist), - // ); - // // return ArtistCard(artist); - // }, - // ), - // ), - // AsyncError(:final error) => SliverToBoxAdapter( - // child: Center( - // child: Text(error.toString()), - // ), - // ), - // _ => const SliverToBoxAdapter( - // child: Center(child: CircularProgressIndicator()), - // ), - // }; + return switch (relatedArtists) { + AsyncData(value: final artists) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: artists.items.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = artists.items.elementAt(index); + return SizedBox( + width: 180, + child: ArtistCard(artist), + ); + }, + ), + ), + AsyncError(:final error) => SliverToBoxAdapter( + child: Center( + child: Text(error.toString()), + ), + ), + _ => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + }; } } diff --git a/lib/provider/metadata_plugin/artist/related.dart b/lib/provider/metadata_plugin/artist/related.dart new file mode 100644 index 00000000..c6a80f75 --- /dev/null +++ b/lib/provider/metadata_plugin/artist/related.dart @@ -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 { + @override + Future> 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, + String>( + () => MetadataPluginArtistRelatedArtistsNotifier(), +); diff --git a/lib/provider/track_options/track_options_provider.dart b/lib/provider/track_options/track_options_provider.dart new file mode 100644 index 00000000..a1c8b785 --- /dev/null +++ b/lib/provider/track_options/track_options_provider.dart @@ -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 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 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 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? progressNotifier, +}); + +final trackOptionActionsProvider = + Provider.family( + (ref, track) => TrackOptionsActions(ref, track), +); + +final trackOptionsStateProvider = + Provider.family((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, + ); +});