feat(player): refine mini-player and queue feedback

This commit is contained in:
zarzet committed 2026-10-14 23:04:56 +07:00
1 parent 83a55fb80b
commit 8d1a42e01a
3 files changed
+151 -36

No files matched your search

+96 -28
View File
@@ -1,6 +1,9 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/app_icon_button.dart';
@@ -10,7 +13,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -66,6 +71,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
final controller = ref.read(musicPlayerControllerProvider);
final colorScheme = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final expressive = materialExpressiveEnabled(context);
final compact = mornye && widget.compact;
// Frosted and flat glass levels run on constrained Android hardware. They
// skip keeping a paused video decoder for the full player's motion cover,
@@ -137,6 +143,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
_MiniPlayerProgress(
duration: mediaItem.duration ?? Duration.zero,
backgroundColor: colorScheme.surfaceContainerHighest,
playing: isPlaying,
),
Padding(
padding: EdgeInsets.symmetric(
@@ -149,7 +156,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
tag: kNowPlayingArtworkHeroTag,
child: ClipRRect(
key: _artworkKey,
borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(mornye ? 6 : 12),
child: SizedBox(
width: context.tokens.coverMini,
height: context.tokens.coverMini,
@@ -209,29 +216,58 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
],
),
),
AppIconButton(
color: mornye ? colorScheme.onSurface : null,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
icon: isLoading
? const AppLoadingIndicator(
size: 20,
strokeWidth: 2.5,
)
: AppIcon(
mornye
? (isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill)
: (isPlaying
? Icons.pause
: Icons.play_arrow),
),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
),
if (expressive)
// Round while paused, square while playing, like the
// full player's play-pause.
AppIconButton.filled(
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
isSelected: isPlaying,
style: IconButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
icon: isLoading
? AppLoadingIndicator(
size: 20,
color: colorScheme.onPrimary,
)
: const AppIcon(Icons.play_arrow),
selectedIcon: isLoading
? AppLoadingIndicator(
size: 20,
color: colorScheme.onPrimary,
)
: const AppIcon(Icons.pause),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
)
else
AppIconButton(
color: mornye ? colorScheme.onSurface : null,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
icon: isLoading
? const AppLoadingIndicator(
size: 20,
strokeWidth: 2.5,
)
: AppIcon(
mornye
? (isPlaying
? CupertinoIcons.pause_fill
: CupertinoIcons.play_fill)
: (isPlaying
? Icons.pause
: Icons.play_arrow),
),
onPressed: isLoading
? null
: () => controller.togglePlayPause(isPlaying),
),
if (!compact)
AppIconButton(
color: mornye ? colorScheme.onSurface : null,
@@ -269,10 +305,12 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
class _MiniPlayerProgress extends ConsumerWidget {
final Duration duration;
final Color backgroundColor;
final bool playing;
const _MiniPlayerProgress({
required this.duration,
required this.backgroundColor,
required this.playing,
});
@override
@@ -282,10 +320,40 @@ class _MiniPlayerProgress extends ConsumerWidget {
final progress = durationMs > 0
? (positionMs / durationMs).clamp(0.0, 1.0)
: 0.0;
return LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: backgroundColor,
if (!materialExpressiveEnabled(context)) {
return LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: backgroundColor,
);
}
final motion = AppMotion.effects(
context,
const AppMotionSpec(Duration(milliseconds: 300), Curves.easeOut),
);
// The wave settles flat while paused; its ticker stops once flat so an
// idle mini player does not repaint every frame.
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: TweenAnimationBuilder<double>(
tween: Tween(end: playing ? 1 : 0),
duration: motion.duration,
curve: motion.curve,
builder: (context, amplitude, _) => TickerMode(
enabled: amplitude > 0,
child: MaterialExpressiveScope(
child: M3EProgressIndicator.linearWavy(
value: progress,
linearSize: M3EProgressIndicatorSize.s,
trackColor: backgroundColor,
// Taper toward both ends instead of the theme's hard cutoff.
amplitudeForProgress: (value) =>
amplitude *
(math.min(value, 1 - value) / 0.05).clamp(0.0, 1.0),
),
),
),
),
);
}
}
+2 -1
View File
@@ -55,7 +55,8 @@ class _PlayerQueueDismissibleState extends State<PlayerQueueDismissible> {
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(12),
// Matches the queue sheet's row corners.
borderRadius: BorderRadius.circular(16),
),
child: AppIcon(
Icons.delete_outline,
+53 -7
View File
@@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_3_expressive/material_3_expressive.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/theme/app_motion.dart';
import 'package:spotiflac_android/theme/material_expressive.dart';
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_icon.dart';
import 'package:spotiflac_android/widgets/expressive_button.dart';
import 'package:spotiflac_android/widgets/player_playback_controls.dart';
@@ -11,7 +15,7 @@ import 'package:spotiflac_android/widgets/player_queue_dismissible.dart';
Future<void> showPlayerQueueSheet(
BuildContext context,
ColorScheme colorScheme,
) => showModalBottomSheet<void>(
) => showAppModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
@@ -38,12 +42,20 @@ class PlayerQueueSheet extends ConsumerWidget {
final ColorScheme colorScheme;
final ScrollController scrollController;
static const _rowRadius = 16.0;
static final _liftSpring = SpringCurve(M3EMotion.expressiveSpatialFast);
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(playQueueProvider).value ?? const [];
final current = ref.watch(currentMediaItemProvider).value;
final controller = ref.read(musicPlayerControllerProvider);
final textTheme = Theme.of(context).textTheme;
final expressive = materialExpressiveEnabled(context);
final artworkMotion = AppMotion.spatial(
context,
const AppMotionSpec(Duration.zero, Curves.linear),
);
return Column(
children: [
Padding(
@@ -112,11 +124,31 @@ class PlayerQueueSheet extends ConsumerWidget {
controller.moveQueueItem(oldIndex, newIndex);
},
proxyDecorator: (child, index, animation) {
return Material(
elevation: 4,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
if (!expressive) {
return Material(
elevation: 4,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
child: child,
);
}
// The lifted row springs up slightly larger and rounder.
return AnimatedBuilder(
animation: animation,
child: child,
builder: (context, child) {
final t = _liftSpring.transform(animation.value);
return Transform.scale(
scale: 1 + 0.03 * t,
child: Material(
elevation: 6 * t.clamp(0.0, 1.0),
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(_rowRadius + 8 * t),
clipBehavior: Clip.antiAlias,
child: child,
),
);
},
);
},
itemBuilder: (context, i) {
@@ -130,9 +162,23 @@ class PlayerQueueSheet extends ConsumerWidget {
selected: isCurrent,
selectedTileColor: colorScheme.secondaryContainer
.withValues(alpha: 0.55),
shape: expressive
? RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_rowRadius),
)
: null,
contentPadding: const EdgeInsets.only(left: 16, right: 4),
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
// The playing song's artwork rounds into a circle.
leading: TweenAnimationBuilder<double>(
tween: Tween(end: expressive && isCurrent ? 22 : 8),
duration: artworkMotion.duration,
curve: artworkMotion.curve,
builder: (context, radius, child) => ClipRRect(
borderRadius: BorderRadius.circular(
radius.clamp(0.0, 22.0),
),
child: child,
),
child: SizedBox.square(
dimension: 44,
child: PlayerArtwork(