diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 38780e43..ac7e1721 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -7,8 +7,10 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; @@ -29,6 +31,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; +import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mornye_volume_control.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; @@ -1366,6 +1369,7 @@ class _NowPlayingScreenState extends ConsumerState { ) async { final action = await showMornyeContextMenu( context: titleContext, + preferAbove: true, builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem), ); if (!mounted) return; @@ -1375,12 +1379,42 @@ class _NowPlayingScreenState extends ConsumerState { source: mediaItem.extras?['source']?.toString() ?? '', ); } else if (action == 'artist') { - final track = ref.read(playerCollectionTrackProvider(mediaItem)).value; - await navigateToArtist( - context, - artistName: mediaItem.artist ?? '', - artistId: track?.artistId, - extensionId: track?.source, + await _goToCurrentArtist(mediaItem); + } + } + + Future _goToCurrentArtist(MediaItem mediaItem) async { + final track = ref.read(playerCollectionTrackProvider(mediaItem)).value; + await navigateToArtist( + context, + artistName: mediaItem.artist ?? '', + artistId: track?.artistId, + extensionId: track?.source, + ); + } + + Future _updatePlayerCollection( + MediaItem item, { + required bool favorite, + }) async { + try { + final provider = playerCollectionTrackProvider(item); + if (ref.read(provider).hasError) ref.invalidate(provider); + final track = await ref.read(provider.future); + if (!mounted) return; + if (favorite) { + await ref.read(libraryCollectionsProvider.notifier).toggleLoved(track); + } else { + await showAddTrackToPlaylistSheet(context, ref, track); + } + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarError(context.friendlyError(error)), + ), + ), ); } } @@ -1405,6 +1439,7 @@ class _NowPlayingScreenState extends ConsumerState { ? await showMornyeContextMenu( context: context, anchor: anchor, + preferAbove: true, builder: (_) => MornyePlayerActionsSheet( mediaItem: mediaItem, sleepTimerSubtitle: sleepTimerSubtitle, @@ -1456,6 +1491,29 @@ class _NowPlayingScreenState extends ConsumerState { ); if (!mounted || !context.mounted) return; switch (action) { + case 'favorite': + await _updatePlayerCollection(mediaItem, favorite: true); + break; + case 'playlist': + await _updatePlayerCollection(mediaItem, favorite: false); + break; + case 'artist': + await _goToCurrentArtist(mediaItem); + break; + case 'share': + await SharePlus.instance.share( + ShareParams( + text: '${mediaItem.title} — ${mediaItem.artist ?? ''}', + sharePositionOrigin: + anchor ?? + Rect.fromCenter( + center: MediaQuery.sizeOf(context).center(Offset.zero), + width: 1, + height: 1, + ), + ), + ); + break; case 'album': await _goToCurrentAlbum(mediaItem: mediaItem, source: source); break; diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart index 22866d74..3d842a59 100644 --- a/lib/widgets/mornye_context_menu.dart +++ b/lib/widgets/mornye_context_menu.dart @@ -17,6 +17,7 @@ Future showMornyeContextMenu({ required BuildContext context, required WidgetBuilder builder, Rect? anchor, + bool preferAbove = false, }) { final navigator = Navigator.of(context, rootNavigator: true); final themes = InheritedTheme.capture(from: context, to: navigator.context); @@ -40,11 +41,13 @@ Future showMornyeContextMenu({ child: CustomSingleChildLayout( delegate: _MenuLayout( anchor: target, + preferAbove: preferAbove, padding: media.padding.copyWith( bottom: math.max(media.padding.bottom, media.viewInsets.bottom), ), ), child: ScaleTransition( + alignment: preferAbove ? Alignment.bottomRight : Alignment.center, scale: animation.drive(Tween(begin: 0.96, end: 1)), child: themes.wrap(Builder(builder: builder)), ), @@ -56,10 +59,15 @@ Future showMornyeContextMenu({ } class _MenuLayout extends SingleChildLayoutDelegate { - const _MenuLayout({required this.anchor, required this.padding}); + const _MenuLayout({ + required this.anchor, + required this.padding, + required this.preferAbove, + }); final Rect? anchor; final EdgeInsets padding; + final bool preferAbove; static const double _margin = 16; @override @@ -68,14 +76,13 @@ class _MenuLayout extends SingleChildLayoutDelegate { 320.0, math.max(0.0, constraints.maxWidth - padding.horizontal - _margin * 2), ); - return BoxConstraints( - minWidth: width, - maxWidth: width, - maxHeight: math.max( - 0, - constraints.maxHeight - padding.vertical - _margin * 2, - ), + var height = math.max( + 0.0, + constraints.maxHeight - padding.vertical - _margin * 2, ); + final above = (anchor?.top ?? 0) - padding.top - _margin - 8; + if (preferAbove && above >= 160) height = math.min(height, above); + return BoxConstraints(minWidth: width, maxWidth: width, maxHeight: height); } @override @@ -93,17 +100,32 @@ class _MenuLayout extends SingleChildLayoutDelegate { final x = anchor == null ? (size.width - childSize.width) / 2 : anchor!.right - childSize.width; - var y = anchor == null ? size.height * 0.18 : anchor!.bottom + 8; - if (anchor != null && y > bottom) { - y = anchor!.top - childSize.height - 8; - if (y < top) y = bottom; + var y = size.height * 0.18; + if (anchor != null) { + final above = anchor!.top - childSize.height - 8; + final below = anchor!.bottom + 8; + if (preferAbove) { + y = above >= top + ? above + : below <= bottom + ? below + : top; + } else { + y = below <= bottom + ? below + : above >= top + ? above + : bottom; + } } return Offset(x.clamp(left, right), y.clamp(top, bottom)); } @override bool shouldRelayout(_MenuLayout oldDelegate) => - anchor != oldDelegate.anchor || padding != oldDelegate.padding; + anchor != oldDelegate.anchor || + padding != oldDelegate.padding || + preferAbove != oldDelegate.preferAbove; } class MornyeMenuAction { @@ -131,11 +153,13 @@ class MornyeContextMenu extends StatelessWidget { this.quickActions = const [], required this.groups, this.inheritSurface = false, + this.dense = false, }); final List quickActions; final List> groups; final bool inheritSurface; + final bool dense; @override Widget build(BuildContext context) { @@ -222,7 +246,11 @@ class MornyeContextMenu extends StatelessWidget { textAlign: compact ? TextAlign.center : TextAlign.start, style: theme.textTheme.bodyLarge?.copyWith( color: color, - fontSize: compact ? 13 : 17, + fontSize: compact + ? 13 + : dense + ? 15 + : 17, fontWeight: compact ? FontWeight.w600 : FontWeight.w400, ), ); @@ -231,7 +259,7 @@ class MornyeContextMenu extends StatelessWidget { child: CupertinoButton( padding: compact ? const EdgeInsets.symmetric(horizontal: 4, vertical: 10) - : const EdgeInsets.symmetric(horizontal: 20, vertical: 13), + : EdgeInsets.symmetric(horizontal: 20, vertical: dense ? 10 : 13), onPressed: action.onPressed, child: compact ? Column( diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart index 6cb42c95..69be7e9a 100644 --- a/lib/widgets/mornye_player_actions_sheet.dart +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -1,7 +1,10 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; @@ -16,16 +19,20 @@ class MornyePlayerNavigationMenu extends StatelessWidget { Widget build(BuildContext context) { final theme = MornyeTheme.build(Brightness.dark); final art = mediaItem.artUri; + final source = art?.scheme == 'file' ? art!.toFilePath() : art?.toString(); return Theme( data: theme.copyWith( colorScheme: theme.colorScheme.copyWith(primary: Colors.grey), ), child: CoverPaletteBuilder( - imageSource: art?.scheme == 'file' - ? art!.toFilePath() - : art?.toString(), - builder: (context, palette) { - final dominant = HSLColor.fromColor(palette.primary); + imageSource: source, + builder: (context, _) { + final dominant = HSLColor.fromColor( + source == null + ? Colors.grey + : CoverPalette.sourceColor(source, Brightness.dark) ?? + Colors.grey, + ); final surface = dominant .withSaturation(dominant.saturation.clamp(0.0, 0.28)) .withLightness(0.28) @@ -38,6 +45,7 @@ class MornyePlayerNavigationMenu extends StatelessWidget { ), child: MornyeContextMenu( inheritSurface: true, + dense: true, groups: [ [ if ((mediaItem.artist ?? '').trim().isNotEmpty) @@ -65,7 +73,7 @@ class MornyePlayerNavigationMenu extends StatelessWidget { } /// A floating menu that inherits the player's dark appearance. -class MornyePlayerActionsSheet extends StatelessWidget { +class MornyePlayerActionsSheet extends ConsumerWidget { const MornyePlayerActionsSheet({ super.key, required this.mediaItem, @@ -76,40 +84,81 @@ class MornyePlayerActionsSheet extends StatelessWidget { final String? sleepTimerSubtitle; @override - Widget build(BuildContext context) => MornyeContextMenu( - groups: [ - [ - if ((mediaItem.album ?? '').trim().isNotEmpty) + Widget build(BuildContext context, WidgetRef ref) { + final track = ref.watch(playerCollectionTrackProvider(mediaItem)).value; + final loved = ref.watch( + libraryCollectionsProvider.select( + (state) => track != null && state.isLoved(track), + ), + ); + return MornyeContextMenu( + dense: true, + quickActions: [ + _action( + context, + 'favorite', + loved ? context.l10n.mornyeFavorited : context.l10n.mornyeFavorite, + CupertinoIcons.star_fill, + selected: loved, + ), + _action( + context, + 'share', + context.l10n.trackMetadataShare, + CupertinoIcons.share_solid, + ), + ], + groups: [ + [ _action( context, - 'album', - context.l10n.homeGoToAlbum, - CupertinoIcons.square_stack, + 'playlist', + context.l10n.collectionAddToPlaylist, + CupertinoIcons.text_badge_plus, ), - _action( - context, - 'details', - context.l10n.nowPlayingDetails, - CupertinoIcons.info, - ), + ], + [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + _action( + context, + 'album', + context.l10n.homeGoToAlbum, + CupertinoIcons.square_stack, + subtitle: mediaItem.album, + ), + if ((mediaItem.artist ?? '').trim().isNotEmpty) + _action( + context, + 'artist', + context.l10n.mornyeGoToArtist, + CupertinoIcons.mic, + subtitle: mediaItem.artist, + ), + _action( + context, + 'details', + context.l10n.nowPlayingDetails, + CupertinoIcons.info, + ), + ], + [ + _action( + context, + 'sleepTimer', + context.l10n.nowPlayingSleepTimer, + CupertinoIcons.moon_zzz, + subtitle: sleepTimerSubtitle, + ), + _action( + context, + 'external', + context.l10n.nowPlayingOpenInExternalPlayer, + CupertinoIcons.arrow_up_right_square, + ), + ], ], - [ - _action( - context, - 'sleepTimer', - context.l10n.nowPlayingSleepTimer, - CupertinoIcons.moon_zzz, - subtitle: sleepTimerSubtitle, - ), - _action( - context, - 'external', - context.l10n.nowPlayingOpenInExternalPlayer, - CupertinoIcons.arrow_up_right_square, - ), - ], - ], - ); + ); + } MornyeMenuAction _action( BuildContext context, @@ -117,10 +166,12 @@ class MornyePlayerActionsSheet extends StatelessWidget { String label, IconData icon, { String? subtitle, + bool selected = false, }) => MornyeMenuAction( icon: icon, label: label, subtitle: subtitle, + selected: selected, onPressed: () => Navigator.of(context).pop(value), ); } diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart index 8f13ca93..f5e704a9 100644 --- a/test/mornye_context_menu_test.dart +++ b/test/mornye_context_menu_test.dart @@ -17,6 +17,7 @@ void main() { Brightness brightness = Brightness.light, double textScale = 1, bool reduceMotion = false, + bool preferAbove = false, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -41,6 +42,7 @@ void main() { final result = await showMornyeContextMenu( context: context, anchor: anchor, + preferAbove: preferAbove, builder: (menuContext) { MornyeMenuAction action(String label, IconData icon) => MornyeMenuAction( @@ -124,6 +126,7 @@ void main() { anchor: const Rect.fromLTWH(600, 200, 24, 44), size: const Size(640, 320), textScale: 2.5, + preferAbove: true, onResult: (value) => result = value, ); final rect = tester.getRect(find.byType(MornyeContextMenu)); @@ -147,8 +150,13 @@ void main() { tester, anchor: const Rect.fromLTWH(12, 40, 44, 44), reduceMotion: true, + preferAbove: true, onResult: results.add, ); + expect( + tester.getRect(find.byType(MornyeContextMenu)).top, + greaterThanOrEqualTo(84), + ); await tester.tapAt(const Offset(8, 800)); await tester.pump(); expect(results, [null]);