feat(player): expand compact track action menus

Add favorite, playlist, sharing, and artist navigation actions. Use denser menu typography and prefer placement above the invoking control.
This commit is contained in:
zarzet
2026-09-25 01:22:03 +07:00
parent cec9d5cbce
commit 3d05383e00
4 changed files with 202 additions and 57 deletions
+64 -6
View File
@@ -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<NowPlayingScreen> {
) async {
final action = await showMornyeContextMenu<String>(
context: titleContext,
preferAbove: true,
builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem),
);
if (!mounted) return;
@@ -1375,12 +1379,42 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
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<void> _goToCurrentArtist(MediaItem mediaItem) async {
final track = ref.read(playerCollectionTrackProvider(mediaItem)).value;
await navigateToArtist(
context,
artistName: mediaItem.artist ?? '',
artistId: track?.artistId,
extensionId: track?.source,
);
}
Future<void> _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<NowPlayingScreen> {
? await showMornyeContextMenu<String>(
context: context,
anchor: anchor,
preferAbove: true,
builder: (_) => MornyePlayerActionsSheet(
mediaItem: mediaItem,
sleepTimerSubtitle: sleepTimerSubtitle,
@@ -1456,6 +1491,29 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
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;
+43 -15
View File
@@ -17,6 +17,7 @@ Future<T?> showMornyeContextMenu<T>({
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<T?> showMornyeContextMenu<T>({
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<double>(begin: 0.96, end: 1)),
child: themes.wrap(Builder(builder: builder)),
),
@@ -56,10 +59,15 @@ Future<T?> showMornyeContextMenu<T>({
}
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<MornyeMenuAction> quickActions;
final List<List<MornyeMenuAction>> 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(
+87 -36
View File
@@ -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),
);
}
+8
View File
@@ -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<String>(
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]);