feat(flutter): mini ↔ full player transition + flesh out NowPlayingScreen
Mini player simplifies to track-info + prev/play/next. The shuffle/repeat/queue cluster moves to the full player, where it has the room to breathe. Full player (NowPlayingScreen) now has: - Real album art (FileImage when artUri is file://, ServerImage from /api/albums/<id>/cover otherwise — same auth-aware loader as the rest of the app, with errorBuilder so a failed fetch falls back to a slate placeholder instead of a broken-image glyph). - Title row with inline like + kebab. - Full-width seek with start/end timestamps below. - 36/72/36 prev/play/next. - Secondary row: shuffle, repeat, queue. Mini ↔ Full transition mechanics: - Tap mini → push /now-playing with custom slide-up transition (CustomTransitionPage, 280ms easeOutCubic). - Vertical drag-up flick on mini (>200 px/s) → same push. - System back / leading expand-more icon → Navigator.pop, which the CustomTransitionPage replays as a slide-down (240ms easeInCubic). - Drag-down on full player past 80px OR a >500 px/s downward flick → Navigator.pop. Buttons and the seek slider stay tappable since Flutter's gesture arena gives priority to the more-specific child recognizers. - Mini player hides while /now-playing is the active route — the full player IS the player UI in that state, no need to fight over visual space.
This commit is contained in:
@@ -12,20 +12,20 @@ import '../shared/widgets/track_actions/track_actions_button.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'player_provider.dart';
|
||||
|
||||
/// Compact player bar mounted in the app shell. Layout:
|
||||
/// Compact player bar mounted at the bottom of the app shell. Mini
|
||||
/// view only — the heavyweight shuffle/repeat/queue/volume controls
|
||||
/// live in NowPlayingScreen, accessible by tap or swipe-up.
|
||||
///
|
||||
/// ┌──────────────────────────────┬──────────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ 🔀 🔁 ☰ │
|
||||
/// │ Artist │ ⏮ ⏯ ⏭ │
|
||||
/// ├──────────────────────────────┴──────────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └───────────────────────────────────────────────┘
|
||||
/// ┌─────────────────────────────────────┬──────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │
|
||||
/// │ Artist │ │
|
||||
/// ├─────────────────────────────────────────────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └─────────────────────────────────────────────────┘
|
||||
///
|
||||
/// Track info gets a 2:1 share of the row vs the controls column on
|
||||
/// the right. Heart + kebab stay inline at the right of the title.
|
||||
/// Right column stacks shuffle/repeat/queue above prev/play/next.
|
||||
/// Volume is handled by the phone's hardware buttons. Bottom:
|
||||
/// full-width seek + time labels.
|
||||
/// Tap anywhere on the bar (including art/title) ascends to the full
|
||||
/// player. A vertical drag upward also ascends, so the gesture mirrors
|
||||
/// the drag-down dismissal on the full screen.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@@ -41,38 +41,43 @@ class PlayerBar extends ConsumerWidget {
|
||||
|
||||
return Material(
|
||||
color: fs.iron,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Top row: track info (2 fill) | controls column (1 fill).
|
||||
// The controls column stacks shuffle/repeat/queue above the
|
||||
// prev/play/next row.
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(flex: 2, child: _TrackInfoColumn(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _ControlsColumn(playback: playback),
|
||||
),
|
||||
],
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/now-playing'),
|
||||
// Swipe up anywhere on the bar to expand into the full player.
|
||||
// We only act on a clear upward flick (>200 px/s) so a slow
|
||||
// tap-with-tiny-jitter doesn't accidentally open the screen.
|
||||
onVerticalDragEnd: (d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -200) context.push('/now-playing');
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: _TrackInfo(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_SeekRow(position: pos, duration: dur),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
_SeekRow(position: pos, duration: dur),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TrackInfoColumn extends StatelessWidget {
|
||||
const _TrackInfoColumn({required this.media});
|
||||
class _TrackInfo extends StatelessWidget {
|
||||
const _TrackInfo({required this.media});
|
||||
final MediaItem media;
|
||||
|
||||
@override
|
||||
@@ -80,209 +85,130 @@ class _TrackInfoColumn extends StatelessWidget {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push('/now-playing'),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (media.artUri != null)
|
||||
Image(
|
||||
// artUri is a file:// path written by AlbumCoverCache —
|
||||
// FileImage is the right loader; NetworkImage would
|
||||
// attempt HTTP on a file:// URI and fail.
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
)
|
||||
else
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title row: title text + inline like + kebab. The
|
||||
// like/kebab buttons get their default 48dp tap target
|
||||
// shrunk to 32dp so the title has room — otherwise it
|
||||
// collapses to nothing in the available width.
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: LikeButton(
|
||||
kind: LikeKind.track,
|
||||
id: media.id,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlsColumn extends ConsumerWidget {
|
||||
const _ControlsColumn({required this.playback});
|
||||
final PlaybackState? playback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Top sub-row: shuffle / repeat / queue
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_IconBtn(
|
||||
size: 32,
|
||||
iconSize: 18,
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icons.shuffle,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
_IconBtn(
|
||||
size: 32,
|
||||
iconSize: 18,
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
_IconBtn(
|
||||
size: 32,
|
||||
iconSize: 18,
|
||||
tooltip: 'Queue',
|
||||
icon: Icons.queue_music,
|
||||
color: fs.ash,
|
||||
onPressed: () => context.push('/queue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Bottom sub-row: prev / play / next. Sized to match the
|
||||
// shuffle/repeat/queue row above (3×32 = 96dp) with the play
|
||||
// button slightly larger; total 32+40+32 = 104dp.
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_IconBtn(
|
||||
size: 32,
|
||||
iconSize: 20,
|
||||
icon: Icons.skip_previous,
|
||||
color: fs.parchment,
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
_IconBtn(
|
||||
size: 40,
|
||||
iconSize: 26,
|
||||
icon: isPlaying
|
||||
? Icons.pause_circle_filled
|
||||
: Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
_IconBtn(
|
||||
size: 32,
|
||||
iconSize: 20,
|
||||
icon: Icons.skip_next,
|
||||
color: fs.parchment,
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
],
|
||||
if (media.artUri != null)
|
||||
Image(
|
||||
image: media.artUri!.isScheme('file')
|
||||
? FileImage(File.fromUri(media.artUri!)) as ImageProvider
|
||||
: NetworkImage(media.artUri.toString()),
|
||||
width: 48,
|
||||
height: 48,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
)
|
||||
else
|
||||
Container(width: 48, height: 48, color: fs.slate),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Title row: title text + inline like + kebab. The
|
||||
// like/kebab buttons get their default 48dp tap target
|
||||
// shrunk to 32dp so the title has room.
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: LikeButton(
|
||||
kind: LikeKind.track,
|
||||
id: media.id,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconBtn extends StatelessWidget {
|
||||
const _IconBtn({
|
||||
required this.size,
|
||||
required this.iconSize,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onPressed,
|
||||
this.tooltip,
|
||||
});
|
||||
final double size;
|
||||
final double iconSize;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onPressed;
|
||||
final String? tooltip;
|
||||
class _PlayControls extends StatelessWidget {
|
||||
const _PlayControls({required this.playback, required this.ref});
|
||||
final PlaybackState? playback;
|
||||
final WidgetRef ref;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: tooltip,
|
||||
iconSize: iconSize,
|
||||
icon: Icon(icon, color: color),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 32,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 22,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user