Files
minstrel/flutter_client/lib/player/player_bar.dart
T
bvandeusen a3985f1138 fix(flutter): cover images — wait for token, file:// loader, graceful errors
The 401s on /api/albums/<id>/cover were the root cause of "only the
first tile is navigable" — failed images leaving slate placeholders
that combined with the deferToChild hit-test (fixed in d703fc2) to
silently swallow taps.

ServerImage:
- sessionTokenProvider is a FutureProvider; .value is null after a
  hot restart until secure-storage resolves. Old code fired
  Image.network with headers:null at that moment, the network image
  cache locked in the 401 response, and never retried. Switch to
  AsyncValue.when so the widget waits for the token before mounting
  Image.network with the auth header attached.
- Add errorBuilder so a single failed image renders the parent
  fallback instead of leaking a stack trace + broken-icon glyph.

Player bar:
- media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping
  it in NetworkImage attempts HTTP on a file:// URI and fails. Use
  FileImage when the scheme is file, else NetworkImage.

Net effect: failed image loads no longer leak errors or block other
widgets from rendering / receiving taps.
2026-05-11 07:44:28 -04:00

363 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 in the app shell. Layout:
///
/// ┌──────────────────────────────┬──────────────┐
/// │ [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.
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 (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),
),
],
),
),
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(
// 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,
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(),
),
],
),
],
);
}
}
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;
@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,
),
);
}
}
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: '',
);