From 1540999f50d7ba78d9706d73127483b98d800d86 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 10 Sep 2023 22:50:44 +0600 Subject: [PATCH] feat: right click to open track option --- .../adaptive/adaptive_pop_sheet_list.dart | 25 ++ .../shared/track_table/track_options.dart | 369 +++++++++--------- .../shared/track_table/track_tile.dart | 310 ++++++++------- 3 files changed, 377 insertions(+), 327 deletions(-) diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index 41534cb32..21f56a220 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -78,6 +78,31 @@ class AdaptivePopSheetList extends StatelessWidget { 'Either icon or child must be provided', ); + Future showPopupMenu(BuildContext context, RelativeRect position) { + final mediaQuery = MediaQuery.of(context); + + return showMenu( + context: context, + useRootNavigator: useRootNavigator, + constraints: BoxConstraints( + maxHeight: mediaQuery.size.height * 0.6, + ), + position: position, + items: children + .map( + (item) => PopupMenuItem( + padding: EdgeInsets.zero, + enabled: false, + child: _AdaptivePopSheetListItem( + item: item, + onSelected: onSelected, + ), + ), + ) + .toList(), + ); + } + @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart index a1bc3fef2..96bd8b603 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_table/track_options.dart @@ -14,7 +14,6 @@ import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -40,9 +39,11 @@ class TrackOptions extends HookConsumerWidget { final Track track; final bool userPlaylist; final String? playlistId; + final ObjectRef?>? showMenuCbRef; const TrackOptions({ Key? key, required this.track, + this.showMenuCbRef, this.userPlaylist = false, this.playlistId, }) : super(key: key); @@ -114,210 +115,216 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - child: AdaptivePopSheetList( - onSelected: (value) async { - switch (value) { - case TrackOptionValue.delete: - await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); - break; - case TrackOptionValue.addToQueue: - await playback.addTrack(track); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.added_track_to_queue(track.name!), - ), - ), - ); - } - break; - case TrackOptionValue.playNext: - playback.addTracksAtFirst([track]); + final adaptivePopSheetList = AdaptivePopSheetList( + onSelected: (value) async { + switch (value) { + case TrackOptionValue.delete: + await File((track as LocalTrack).path).delete(); + ref.refresh(localTracksProvider); + break; + case TrackOptionValue.addToQueue: + await playback.addTrack(track); + if (context.mounted) { scaffoldMessenger.showSnackBar( SnackBar( content: Text( - context.l10n.track_will_play_next(track.name!), + context.l10n.added_track_to_queue(track.name!), ), ), ); - break; - case TrackOptionValue.removeFromQueue: - playback.removeTrack(track.id!); - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.removed_track_from_queue( - track.name!, - ), - ), + } + break; + case TrackOptionValue.playNext: + playback.addTracksAtFirst([track]); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.track_will_play_next(track.name!), ), - ); - break; - case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); - break; - case TrackOptionValue.addToPlaylist: - actionAddToPlaylist(context, track); - break; - case TrackOptionValue.removeFromPlaylist: - removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); - break; - case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); - } else { - ref.read(BlackListNotifier.provider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), - ); - } - break; - case TrackOptionValue.share: - actionShare(context, track); - break; - case TrackOptionValue.details: - showDialog( - context: context, - builder: (context) => TrackDetailsDialog(track: track), - ); - break; - case TrackOptionValue.download: - await downloadManager.addToQueue(track); - break; - } - }, - icon: const Icon(SpotubeIcons.moreHorizontal), - headings: [ - ListTile( - dense: true, - leading: AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, - placeholder: ImagePlaceholder.albumArt), - fit: BoxFit.cover, + ), + ); + break; + case TrackOptionValue.removeFromQueue: + playback.removeTrack(track.id!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.removed_track_from_queue( + track.name!, + ), ), ), - ), - title: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, + ); + break; + case TrackOptionValue.favorite: + favorites.toggleTrackLike.mutate(favorites.isLiked); + break; + case TrackOptionValue.addToPlaylist: + actionAddToPlaylist(context, track); + break; + case TrackOptionValue.removeFromPlaylist: + removingTrack.value = track.uri; + removeTrack.mutate(track.uri!); + break; + case TrackOptionValue.blacklist: + if (isBlackListed) { + ref.read(BlackListNotifier.provider.notifier).remove( + BlacklistedElement.track(track.id!, track.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.track(track.id!, track.name!), + ); + } + break; + case TrackOptionValue.share: + actionShare(context, track); + break; + case TrackOptionValue.details: + showDialog( + context: context, + builder: (context) => TrackDetailsDialog(track: track), + ); + break; + case TrackOptionValue.download: + await downloadManager.addToQueue(track); + break; + } + }, + icon: const Icon(SpotubeIcons.moreHorizontal), + headings: [ + ListTile( + dense: true, + leading: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString(track.album!.images, + placeholder: ImagePlaceholder.albumArt), + fit: BoxFit.cover, ), ), ), - ], - children: switch (track.runtimeType) { - LocalTrack => [ + title: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists!, + ), + ), + ), + ], + children: switch (track.runtimeType) { + LocalTrack => [ + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ) + ], + _ => [ + if (!playlist.containsTrack(track)) ...[ PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - if (!playlist.containsTrack(track)) ...[ - PopSheetEntry( - value: TrackOptionValue.addToQueue, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (favorites.me.hasData) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (auth != null) - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), + value: TrackOptionValue.addToQueue, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), ), + ] else PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (favorites.me.hasData) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, ), ), + if (auth != null) PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), ), + if (userPlaylist && auth != null) PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), + value: TrackOptionValue.removeFromPlaylist, + leading: (removeTrack.isMutating || !removeTrack.hasData) && + removingTrack.value == track.uri + ? const CircularProgressIndicator() + : const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), + ), + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, ), - ] - }, + ), + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ] + }, + ); + + //! This is the most ANTI pattern I've ever done, but it works + showMenuCbRef?.value = (relativeRect) { + adaptivePopSheetList.showPopupMenu(context, relativeRect); + }; + + return ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), ), + child: adaptivePopSheetList, ); } } diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index 7926f55aa..9fe13dcc3 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -57,174 +58,191 @@ class TrackTile extends HookConsumerWidget { [blacklist, track], ); + final showOptionCbRef = useRef?>(null); + final isPlaying = track.id == playlist.activeTrack?.id; return LayoutBuilder(builder: (context, constrains) { - return HoverBuilder( - permanentState: isPlaying || constrains.smAndDown ? true : null, - builder: (context, isHovering) { - return ListTile( - selected: isPlaying, - onTap: onTap, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - if (index != null && onChanged == null && constrains.mdAndUp) - SizedBox( - width: 34, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '$index', - maxLines: 1, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, + 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, + ), + ); + }, + child: HoverBuilder( + permanentState: isPlaying || constrains.smAndDown ? true : null, + builder: (context, isHovering) { + return ListTile( + selected: isPlaying, + onTap: onTap, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: + isBlackListed ? theme.colorScheme.errorContainer : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + if (index != null && onChanged == null && constrains.mdAndUp) + SizedBox( + width: 34, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '$index', + maxLines: 1, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, + ), ), + ) + else if (constrains.smAndDown) + const SizedBox(width: 16), + if (onChanged != null) + Checkbox( + value: selected, + onChanged: onChanged, ), - ) - else if (constrains.smAndDown) - const SizedBox(width: 16), - if (onChanged != null) - Checkbox( - value: selected, - onChanged: onChanged, - ), - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, - placeholder: ImagePlaceholder.albumArt, + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AspectRatio( + aspectRatio: 1, + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, ), - fit: BoxFit.cover, ), ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, + ), ), ), - ), - Positioned.fill( - child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: !isHovering - ? const SizedBox.shrink() - : isPlaying && playlist.isFetching - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : const Icon(SpotubeIcons.play), + Positioned.fill( + child: Center( + child: IconTheme( + data: theme.iconTheme + .copyWith(size: 26, color: Colors.white), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isHovering + ? const SizedBox.shrink() + : isPlaying && playlist.isFetching + ? const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: Colors.white, + ), + ) + : isPlaying + ? Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ) + : const Icon(SpotubeIcons.play), + ), ), ), ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ], ), - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), + ], + ), + title: Row( + children: [ Expanded( - flex: 4, - child: switch (track.runtimeType) { - LocalTrack => Text( - track.album!.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( + flex: 6, + child: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: switch (track.runtimeType) { + LocalTrack => Text( track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, + maxLines: 1, overflow: TextOverflow.ellipsis, ), - ) - }, - ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + push: true, + overflow: TextOverflow.ellipsis, + ), + ) + }, + ), + ], ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + TypeConversionUtils.artists_X_String( track.artists ?? [], ), + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: TypeConversionUtils.artists_X_ClickableArtists( + track.artists ?? [], + ), + ), ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - ), - ], - ), - ); - }, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + ], + ), + ); + }, + ), ); }); }