From 8d1a42e01ae39c470f59d136cb8b5d015b8b19f6 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:04:56 +0700 Subject: [PATCH] feat(player): refine mini-player and queue feedback --- lib/widgets/mini_player.dart | 124 +++++++++++++++++----- lib/widgets/player_queue_dismissible.dart | 3 +- lib/widgets/player_queue_sheet.dart | 60 +++++++++-- 3 files changed, 151 insertions(+), 36 deletions(-) diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index ee226f47..a559016a 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -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 { 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 { _MiniPlayerProgress( duration: mediaItem.duration ?? Duration.zero, backgroundColor: colorScheme.surfaceContainerHighest, + playing: isPlaying, ), Padding( padding: EdgeInsets.symmetric( @@ -149,7 +156,7 @@ class _MiniPlayerState extends ConsumerState { 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 { ], ), ), - 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 { 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( + 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), + ), + ), + ), + ), ); } } diff --git a/lib/widgets/player_queue_dismissible.dart b/lib/widgets/player_queue_dismissible.dart index 5b8c5b41..f7da256a 100644 --- a/lib/widgets/player_queue_dismissible.dart +++ b/lib/widgets/player_queue_dismissible.dart @@ -55,7 +55,8 @@ class _PlayerQueueDismissibleState extends State { 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, diff --git a/lib/widgets/player_queue_sheet.dart b/lib/widgets/player_queue_sheet.dart index e8fb8cb8..947c7f25 100644 --- a/lib/widgets/player_queue_sheet.dart +++ b/lib/widgets/player_queue_sheet.dart @@ -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 showPlayerQueueSheet( BuildContext context, ColorScheme colorScheme, -) => showModalBottomSheet( +) => showAppModalBottomSheet( 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( + 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(