mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user