fix(flutter): player bar layout, system playlist tap, banner height
Player bar: drop the volume slider — phone hardware controls volume. Right cluster collapses to a single row of shuffle/repeat/queue. Playlist card: GestureDetector defers to children by default, so taps over the cover image area didn't always register. Add HitTestBehavior.opaque so the whole card surface is tappable. Update banner: was 3-4× the height of its text — IconButton's default 48dp tap target plus generous vertical padding dominated. Tighten padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim the action button to 28dp height with shrinkWrap tap target. Home screen: ClampingScrollPhysics + 140dp bottom padding (already applied in working tree) so scroll stops just above the mini-player.
This commit is contained in:
@@ -58,20 +58,30 @@ class HomeScreen extends ConsumerWidget {
|
||||
),
|
||||
data: (h) => RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(homeProvider.future),
|
||||
child: ListView(children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albums: h.rediscoverAlbums,
|
||||
artists: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
||||
const SizedBox(height: 96),
|
||||
]),
|
||||
child: ListView(
|
||||
// ClampingScrollPhysics: no bounce overscroll past the
|
||||
// content. Combined with the bottom padding below, this
|
||||
// makes "scroll to end" land the last item just above the
|
||||
// player bar — no empty void.
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: [
|
||||
_PlaylistsSection(
|
||||
playlists: allPlaylists.value?.owned ?? const [],
|
||||
status: status.value ?? SystemPlaylistsStatus.empty(),
|
||||
),
|
||||
_RecentlyAddedSection(albums: h.recentlyAddedAlbums),
|
||||
_RediscoverSection(
|
||||
albums: h.rediscoverAlbums,
|
||||
artists: h.rediscoverArtists,
|
||||
),
|
||||
_MostPlayedSection(tracks: h.mostPlayedTracks),
|
||||
_LastPlayedSection(artists: h.lastPlayedArtists),
|
||||
// Bottom padding ≈ player bar height (top row 80 + seek
|
||||
// 30 + padding 16 ≈ ~140) so the last section's bottom
|
||||
// edge lands right above the player when scrolled.
|
||||
const SizedBox(height: 140),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,19 +10,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 mirrors the
|
||||
/// web's compact PlayerBar (#358):
|
||||
/// Compact player bar mounted in the app shell. Layout:
|
||||
///
|
||||
/// ┌──────────┬───────────┬──────────────────┐
|
||||
/// │ art │ ♥ ⋮ │ 🔀 🔁 ☰ │ ← short row
|
||||
/// │ title │ │ │
|
||||
/// │ artist │ ⏮ ⏯ ⏭ │ volume slider │ ← play controls
|
||||
/// ├──────────┴───────────┴──────────────────┤
|
||||
/// │ 0:42 ━━━━━●━━━━━━━━━━━━━━━━━━━━━ 3:21 │
|
||||
/// └─────────────────────────────────────────┘
|
||||
/// ┌────────────────────────────┬────────┬─────────┐
|
||||
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ 🔀 🔁 ☰ │
|
||||
/// │ Artist │ │ │
|
||||
/// ├────────────────────────────┴────────┴─────────┤
|
||||
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
||||
/// └────────────────────────────────────────────────┘
|
||||
///
|
||||
/// Track-info column (cover + title block) tap target opens
|
||||
/// NowPlayingScreen.
|
||||
/// Heart + kebab sit inline at the right of the title (per operator
|
||||
/// "directly to the right of the track name"). Play controls stay
|
||||
/// full-size (40/48/40). Right column: shuffle/repeat/queue. Volume
|
||||
/// is handled by the phone's hardware buttons. Bottom: full-width
|
||||
/// seek + time labels.
|
||||
class PlayerBar extends ConsumerWidget {
|
||||
const PlayerBar({super.key});
|
||||
|
||||
@@ -43,21 +44,16 @@ class PlayerBar extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Top row: track info (with inline like+kebab) | play controls | right cluster
|
||||
IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 35,
|
||||
child: _TrackInfoColumn(media: media),
|
||||
),
|
||||
Expanded(child: _TrackInfoColumn(media: media)),
|
||||
const SizedBox(width: 8),
|
||||
_MiddleColumn(media: media, playback: playback, ref: ref),
|
||||
_PlayControls(playback: playback, ref: ref),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 30,
|
||||
child: _RightColumn(playback: playback),
|
||||
),
|
||||
_RightCluster(playback: playback),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -77,6 +73,8 @@ class _TrackInfoColumn extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final artistName = (media.artist ?? '').trim();
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.push('/now-playing'),
|
||||
child: Row(
|
||||
@@ -99,18 +97,33 @@ class _TrackInfoColumn extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
media.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.parchment, fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
media.artist ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
// Title row: title text + inline like + kebab
|
||||
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),
|
||||
),
|
||||
),
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 18),
|
||||
TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (artistName.isNotEmpty)
|
||||
Text(
|
||||
artistName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: fs.ash, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -120,13 +133,8 @@ class _TrackInfoColumn extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MiddleColumn extends StatelessWidget {
|
||||
const _MiddleColumn({
|
||||
required this.media,
|
||||
required this.playback,
|
||||
required this.ref,
|
||||
});
|
||||
final MediaItem media;
|
||||
class _PlayControls extends StatelessWidget {
|
||||
const _PlayControls({required this.playback, required this.ref});
|
||||
final PlaybackState? playback;
|
||||
final WidgetRef ref;
|
||||
|
||||
@@ -134,73 +142,54 @@ class _MiddleColumn extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final isPlaying = playback?.playing == true;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Top sub-row: like + kebab (shorter than play controls)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LikeButton(kind: LikeKind.track, id: media.id, size: 18),
|
||||
TrackActionsButton(
|
||||
track: _trackRefFromMediaItem(media),
|
||||
hideQueueActions: true,
|
||||
),
|
||||
],
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 22),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
),
|
||||
// Bottom sub-row: play controls — full size 40 / 48 / 40
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(Icons.skip_previous, color: fs.parchment, size: 22),
|
||||
onPressed: () =>
|
||||
ref.read(audioHandlerProvider).skipToPrevious(),
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 28,
|
||||
icon: Icon(
|
||||
isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
color: fs.accent,
|
||||
),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 28,
|
||||
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: 40,
|
||||
height: 40,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 22),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
onPressed: () {
|
||||
final h = ref.read(audioHandlerProvider);
|
||||
if (isPlaying) {
|
||||
h.pause();
|
||||
} else {
|
||||
h.play();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(Icons.skip_next, color: fs.parchment, size: 22),
|
||||
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RightColumn extends ConsumerWidget {
|
||||
const _RightColumn({required this.playback});
|
||||
class _RightCluster extends ConsumerWidget {
|
||||
const _RightCluster({required this.playback});
|
||||
final PlaybackState? playback;
|
||||
|
||||
@override
|
||||
@@ -208,96 +197,56 @@ class _RightColumn extends ConsumerWidget {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
||||
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
||||
final volume = ref.watch(volumeProvider).value ?? 1.0;
|
||||
final actions = ref.read(playerActionsProvider);
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Top sub-row: shuffle / repeat / queue
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
size: 18,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
||||
icon: Icon(
|
||||
Icons.shuffle,
|
||||
size: 18,
|
||||
color: shuffleOn ? fs.accent : fs.ash,
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
size: 18,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
),
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, size: 18, color: fs.ash),
|
||||
onPressed: () => context.push('/queue'),
|
||||
),
|
||||
),
|
||||
],
|
||||
onPressed: actions.toggleShuffle,
|
||||
),
|
||||
),
|
||||
// Bottom sub-row: volume slider
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
volume == 0
|
||||
? Icons.volume_off
|
||||
: (volume < 0.5 ? Icons.volume_down : Icons.volume_up),
|
||||
size: 14,
|
||||
color: fs.ash,
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: switch (repeatMode) {
|
||||
AudioServiceRepeatMode.none => 'Repeat off',
|
||||
AudioServiceRepeatMode.one => 'Repeat one',
|
||||
_ => 'Repeat all',
|
||||
},
|
||||
icon: Icon(
|
||||
repeatMode == AudioServiceRepeatMode.one
|
||||
? Icons.repeat_one
|
||||
: Icons.repeat,
|
||||
size: 18,
|
||||
color: repeatMode == AudioServiceRepeatMode.none
|
||||
? fs.ash
|
||||
: fs.accent,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
overlayShape:
|
||||
const RoundSliderOverlayShape(overlayRadius: 12),
|
||||
),
|
||||
child: Slider(
|
||||
activeColor: fs.accent,
|
||||
inactiveColor: fs.slate,
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: volume.clamp(0.0, 1.0),
|
||||
onChanged: actions.setVolume,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onPressed: actions.cycleRepeat,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
tooltip: 'Queue',
|
||||
icon: Icon(Icons.queue_music, size: 18, color: fs.ash),
|
||||
onPressed: () => context.push('/queue'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ class PlaylistCard extends StatelessWidget {
|
||||
return SizedBox(
|
||||
width: 176,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => context.push('/playlists/${playlist.id}'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
|
||||
@@ -62,11 +62,12 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 4, 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.system_update, color: fs.parchment, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Icon(Icons.system_update, color: fs.parchment, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -76,11 +77,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
_stage == _Stage.error
|
||||
? 'Update failed'
|
||||
: 'Update Minstrel · ${info.version} available',
|
||||
style: TextStyle(color: fs.parchment, fontSize: 13),
|
||||
style: TextStyle(color: fs.parchment, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_stage == _Stage.downloading) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
LinearProgressIndicator(
|
||||
value: _progress > 0 ? _progress : null,
|
||||
minHeight: 2,
|
||||
@@ -89,11 +90,11 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
),
|
||||
],
|
||||
if (_stage == _Stage.error && _error != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(color: fs.ash, fontSize: 11),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
@@ -105,20 +106,35 @@ class _UpdateBannerState extends ConsumerState<UpdateBanner> {
|
||||
onPressed: () => _onInstall(info),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: fs.accent,
|
||||
minimumSize: const Size(64, 36),
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('Install'),
|
||||
child: const Text('Install', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
if (_stage == _Stage.error)
|
||||
TextButton(
|
||||
onPressed: () => _onInstall(info),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.accent),
|
||||
child: const Text('Retry'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: fs.accent,
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
child: const Text('Retry', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 16,
|
||||
icon: Icon(Icons.close, color: fs.ash),
|
||||
onPressed: () => _onDismiss(info),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Dismiss',
|
||||
icon: Icon(Icons.close, size: 18, color: fs.ash),
|
||||
onPressed: () => _onDismiss(info),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user