Files
minstrel/flutter_client/lib/player/player_bar.dart
T
bvandeusen b6b73fdd0c fix(flutter): live seek bar (~200ms) + breathing room above controls
Why the seek bar appeared frozen: it was reading
PlaybackState.updatePosition, which audio_service only updates on
event transitions (play/pause/buffer/seek). Between events it sits
unchanged, so the bar only jumped at intervals.

Expose just_audio's positionStream (~200ms cadence) from the audio
handler, wrap as positionProvider, and read that in both the mini bar
and the full player. Now the bar advances continuously while playing.

While we're here: spread the full player's vertical layout per
operator — gap to seek 20→32, seek-to-primary 8→24, primary-to-
secondary 16→24, plus 24 below to match. The full player had visible
slack above the controls; this redistributes it.
2026-05-11 08:15:58 -04:00

292 lines
10 KiB
Dart

import 'dart:io';
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 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 │
/// └─────────────────────────────────────────────────┘
///
/// 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});
@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();
// positionProvider is just_audio's positionStream (~200ms cadence)
// so the mini bar's seek crawls forward smoothly. PlaybackState
// only updates on event transitions and would leave it frozen.
final pos = ref.watch(positionProvider).value ?? Duration.zero;
final dur = media.duration ?? Duration.zero;
return Material(
color: fs.iron,
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),
],
),
),
),
);
}
}
class _TrackInfo extends StatelessWidget {
const _TrackInfo({required this.media});
final MediaItem media;
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
final artistName = (media.artist ?? '').trim();
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
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 _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: 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(),
),
),
],
);
}
}
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: '',
);