835592f073
Mechanical sweep across 30 files: every Material Icons.* replaced with the signed-off Lucide equivalent + a flutter_lucide import per file. Zero Material Icons.* remain in lib/; no unused imports. Judgment-call mappings: album->disc_3, library_music->library_big, playlist_play->list_video, graphic_eq->audio_lines, system_update->download, restore->archive_restore, download_done->circle_check_big. track_actions_sheet like menu row: collapsed `liked ? favorite : favorite_border` to a single LucideIcons.heart (the row's Like/Unlike text label conveys state). Icon-only LikeButton + the notification keep the filled-vs-outline shape per the design decision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
Dart
347 lines
13 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_lucide/flutter_lucide.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 'now_playing_screen.dart' show kPlayerCoverHeroTag;
|
|
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 ConsumerStatefulWidget {
|
|
const PlayerBar({super.key});
|
|
|
|
@override
|
|
ConsumerState<PlayerBar> createState() => _PlayerBarState();
|
|
}
|
|
|
|
class _PlayerBarState extends ConsumerState<PlayerBar> {
|
|
/// Last non-null artUri we've seen from the mediaItem stream. Held
|
|
/// across the audio_handler's two-broadcast track-change pattern
|
|
/// (bare MediaItem first, then with artUri once AlbumCoverCache
|
|
/// resolves) so the mini bar keeps showing the previous track's
|
|
/// cover until the new one is known. Without this hold the bar
|
|
/// flickered to the slate placeholder for ~100ms on every track
|
|
/// change.
|
|
Uri? _lastArtUri;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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();
|
|
|
|
// Capture the latest non-null artUri so _TrackInfo's cover stays
|
|
// continuous across track changes.
|
|
if (media.artUri != null) _lastArtUri = media.artUri;
|
|
final displayMedia = media.artUri == null && _lastArtUri != null
|
|
? media.copyWith(artUri: _lastArtUri)
|
|
: media;
|
|
|
|
// 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: displayMedia)),
|
|
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();
|
|
|
|
// Cover is wrapped in a Hero with the shared kPlayerCoverHeroTag so
|
|
// tapping the mini bar to expand into NowPlayingScreen animates the
|
|
// artwork from this 48dp footprint to the full-screen size rather
|
|
// than fade-cutting. Tag is stable per-route (not keyed by media.id)
|
|
// so the transition works regardless of what's playing.
|
|
//
|
|
// Why no transition / placeholder handling: the audio_handler
|
|
// broadcasts MediaItem twice on track change — once with artUri
|
|
// null (the new track's bare metadata), then with artUri set once
|
|
// AlbumCoverCache resolves. The widget tree above hands us the
|
|
// most-recent non-null artUri (`displayArtUri`), so the previous
|
|
// track's cover stays visible across that null gap and the new
|
|
// cover snaps in the moment its artUri arrives. Rapid change is
|
|
// fine here per operator preference; AnimatedSwitcher previously
|
|
// smeared the swap and made the slate placeholder visible.
|
|
final displayArtUri = media.artUri;
|
|
final Widget cover;
|
|
if (displayArtUri != null) {
|
|
// CachedNetworkImageProvider for HTTPS art URIs so the mini bar
|
|
// hits the same disk cache the rest of the UI uses (ServerImage,
|
|
// discover thumbnails). FileImage stays for the file:// branch
|
|
// populated by AlbumCoverCache — bytes are already on disk and a
|
|
// second cache layer would only burn duplicate space.
|
|
cover = Image(
|
|
image: displayArtUri.isScheme('file')
|
|
? FileImage(File.fromUri(displayArtUri)) as ImageProvider
|
|
: CachedNetworkImageProvider(displayArtUri.toString()),
|
|
width: 48,
|
|
height: 48,
|
|
// Without a fit, Image paints the source at its intrinsic
|
|
// resolution inside the 48dp box — a thumbnail-sized cover
|
|
// would render as a tiny inset. Cover stretches/crops to fill
|
|
// the box uniformly.
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) =>
|
|
Container(width: 48, height: 48, color: fs.slate),
|
|
);
|
|
} else {
|
|
cover = Container(width: 48, height: 48, color: fs.slate);
|
|
}
|
|
return Row(
|
|
// Default centering vertically aligns the like/kebab buttons
|
|
// against the album art (48dp) — visually they span the title +
|
|
// artist block instead of sitting on the title baseline.
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Hero(
|
|
tag: kPlayerCoverHeroTag,
|
|
child: cover,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
media.title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
|
),
|
|
if (artistName.isNotEmpty)
|
|
Text(
|
|
artistName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Like + kebab as siblings to the title/artist column rather
|
|
// than nested inside the title row. Width stays 32dp each so
|
|
// horizontal footprint matches what we had; height stretches
|
|
// to the row's full 48dp so the icons sit at vertical center
|
|
// against the title+artist block.
|
|
SizedBox(
|
|
width: 32,
|
|
height: 48,
|
|
child: LikeButton(
|
|
kind: LikeKind.track,
|
|
id: media.id,
|
|
size: 20,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 32,
|
|
height: 48,
|
|
child: TrackActionsButton(
|
|
track: _trackRefFromMediaItem(media),
|
|
hideQueueActions: true,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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(LucideIcons.skip_back, color: fs.parchment),
|
|
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
iconSize: 32,
|
|
icon: Icon(
|
|
isPlaying ? LucideIcons.circle_pause : LucideIcons.circle_play,
|
|
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(LucideIcons.skip_forward, 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 ?? '',
|
|
// artist_id is stashed in extras by audio_handler — without it
|
|
// the kebab's "Go to artist" pushes /artists/ (empty id) and 404s.
|
|
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
|
artistName: media.artist ?? '',
|
|
durationSec: media.duration?.inSeconds ?? 0,
|
|
streamUrl: '',
|
|
);
|