f65650fb6d
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.
329 lines
11 KiB
Dart
329 lines
11 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api/endpoints/likes.dart' show LikeKind;
|
|
import '../likes/like_button.dart';
|
|
import '../models/track.dart';
|
|
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:
|
|
///
|
|
/// ┌────────────────────────────┬────────┬─────────┐
|
|
/// │ [art] Title ♥ ⋮ │ ⏮ ⏯ ⏭ │ 🔀 🔁 ☰ │
|
|
/// │ Artist │ │ │
|
|
/// ├────────────────────────────┴────────┴─────────┤
|
|
/// │ 0:00 ━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3:06 │
|
|
/// └────────────────────────────────────────────────┘
|
|
///
|
|
/// 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});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final media = ref.watch(mediaItemProvider).value;
|
|
final playback = ref.watch(playbackStateProvider).value;
|
|
if (media == null) return const SizedBox.shrink();
|
|
|
|
final pos = playback?.updatePosition ?? Duration.zero;
|
|
final dur = media.duration ?? Duration.zero;
|
|
|
|
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 (with inline like+kebab) | play controls | right cluster
|
|
IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(child: _TrackInfoColumn(media: media)),
|
|
const SizedBox(width: 8),
|
|
_PlayControls(playback: playback, ref: ref),
|
|
const SizedBox(width: 8),
|
|
_RightCluster(playback: playback),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
_SeekRow(position: pos, duration: dur),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TrackInfoColumn extends StatelessWidget {
|
|
const _TrackInfoColumn({required this.media});
|
|
final MediaItem media;
|
|
|
|
@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(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (media.artUri != null)
|
|
Image(
|
|
image: 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
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlayControls extends StatelessWidget {
|
|
const _PlayControls({required this.playback, required this.ref});
|
|
final PlaybackState? playback;
|
|
final WidgetRef ref;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final isPlaying = playback?.playing == true;
|
|
return 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,
|
|
),
|
|
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 _RightCluster extends ConsumerWidget {
|
|
const _RightCluster({required this.playback});
|
|
final PlaybackState? playback;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
|
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
|
final actions = ref.read(playerActionsProvider);
|
|
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
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,
|
|
),
|
|
onPressed: actions.toggleShuffle,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
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'),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeekRow extends ConsumerWidget {
|
|
const _SeekRow({required this.position, required this.duration});
|
|
final Duration position;
|
|
final Duration duration;
|
|
|
|
String _fmt(Duration d) {
|
|
final m = d.inMinutes.remainder(60).toString();
|
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$m:$s';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final maxMs =
|
|
duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
_fmt(position),
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(color: fs.ash, fontSize: 10),
|
|
),
|
|
),
|
|
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: maxMs,
|
|
value: position.inMilliseconds
|
|
.toDouble()
|
|
.clamp(0.0, duration.inMilliseconds.toDouble()),
|
|
onChanged: (v) => ref
|
|
.read(audioHandlerProvider)
|
|
.seek(Duration(milliseconds: v.toInt())),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 36,
|
|
child: Text(
|
|
_fmt(duration),
|
|
style: TextStyle(color: fs.ash, fontSize: 10),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Reconstructs a minimal TrackRef from a MediaItem so the
|
|
/// TrackActionsButton has the fields its sheet expects. Mirrors the
|
|
/// pattern used by NowPlayingScreen — extras['album_id'] is what
|
|
/// audio_handler stashed at queue-build time.
|
|
TrackRef _trackRefFromMediaItem(MediaItem media) => TrackRef(
|
|
id: media.id,
|
|
title: media.title,
|
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
|
albumTitle: media.album ?? '',
|
|
artistId: '',
|
|
artistName: media.artist ?? '',
|
|
durationSec: media.duration?.inSeconds ?? 0,
|
|
streamUrl: '',
|
|
);
|