mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: optimize track options and related artists
This commit is contained in:
parent
3b21b05fdc
commit
3a5ddd6214
@ -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();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
],
|
],
|
||||||
|
@ -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(
|
||||||
|
@ -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()),
|
),
|
||||||
// ),
|
};
|
||||||
// };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
32
lib/provider/metadata_plugin/artist/related.dart
Normal file
32
lib/provider/metadata_plugin/artist/related.dart
Normal 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(),
|
||||||
|
);
|
313
lib/provider/track_options/track_options_provider.dart
Normal file
313
lib/provider/track_options/track_options_provider.dart
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user