mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
feat(player): refine mini-player and queue feedback
This commit is contained in:
1 parent
83a55fb80b
commit
8d1a42e01a
3 files changed
+151
-36
No files matched your search
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user