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>
648 lines
24 KiB
Dart
648 lines
24 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.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/server_image.dart';
|
|
import '../shared/widgets/track_actions/track_actions_button.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'album_color_extractor.dart';
|
|
import 'player_provider.dart';
|
|
|
|
/// Hero tag shared between the mini player's cover and the full-screen
|
|
/// _AlbumArt's cover so tapping the mini bar animates the artwork from
|
|
/// the bar's footprint to the full-screen size. Stable per-route (not
|
|
/// keyed by media.id) so the transition works regardless of what's
|
|
/// playing.
|
|
const String kPlayerCoverHeroTag = 'player-cover';
|
|
|
|
/// Duration for the AnimatedSwitcher / AnimatedContainer track-change
|
|
/// crossfade. ~300ms reads as a smooth transition without dragging.
|
|
const Duration _trackChangeDuration = Duration(milliseconds: 300);
|
|
|
|
/// Full-screen player. Mounted on /now-playing. Pushed via a slide-up
|
|
/// transition (see routing.dart). Dismisses three ways:
|
|
///
|
|
/// - System back / leading button → Navigator.pop
|
|
/// - Vertical drag down past threshold → Navigator.pop (matches the
|
|
/// slide-down animation users expect from a "pull down to close"
|
|
/// sheet)
|
|
/// - Tap of the mini player ascends here; reverse motion descends back.
|
|
class NowPlayingScreen extends ConsumerStatefulWidget {
|
|
const NowPlayingScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
|
|
}
|
|
|
|
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
|
// Cumulative drag distance used to decide if a vertical drag should
|
|
// dismiss. Reset on each drag start.
|
|
double _dragOffset = 0;
|
|
|
|
/// The MediaItem currently displayed on the screen. Held separately
|
|
/// from the live mediaItemProvider so we can preload the new track's
|
|
/// cover bytes + dominant color BEFORE flipping the visible state.
|
|
/// Without this gate the full-player UI swapped to the new track's
|
|
/// title/cover slot immediately while the image was still decoding,
|
|
/// producing a visible "pop in" once the bytes arrived.
|
|
MediaItem? _displayedMedia;
|
|
|
|
/// Dominant color of [_displayedMedia]'s cover. Tweened by the
|
|
/// backdrop AnimatedContainer when it changes.
|
|
Color? _displayedDominant;
|
|
|
|
/// The id of the most-recent track we kicked a preload for. Used to
|
|
/// drop stale preload completions when the user skips rapidly past
|
|
/// a track whose cover hadn't finished decoding yet.
|
|
String? _pendingPreloadId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Seed displayed state from the current mediaItem if a track is
|
|
// already playing when the full player is opened. ref.listen
|
|
// below only fires on CHANGES after subscription, so without
|
|
// this seed the screen sits on "Nothing playing." even though
|
|
// the mini bar shows a live track — the listener never gets a
|
|
// mediaItem emission because the stream's current value hasn't
|
|
// changed.
|
|
final current = ref.read(mediaItemProvider).value;
|
|
if (current == null) return;
|
|
_displayedMedia = current;
|
|
// Best-effort synchronous color seed: if albumColorProvider has
|
|
// already extracted this album's dominant color earlier in the
|
|
// session, pull it now so the backdrop renders correctly on
|
|
// first frame. Otherwise the post-frame _scheduleSwap below
|
|
// resolves it asynchronously and AnimatedContainer tweens.
|
|
final albumId = current.extras?['album_id'] as String?;
|
|
if (albumId != null && albumId.isNotEmpty) {
|
|
_displayedDominant =
|
|
ref.read(albumColorProvider(albumId)).asData?.value;
|
|
}
|
|
// Kick the preload pipeline post-frame so the cover bytes are
|
|
// decoded + the dominant color is resolved even when the user
|
|
// opens the player to a track that hasn't yet flowed through
|
|
// ref.listen.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_scheduleSwap(current);
|
|
});
|
|
}
|
|
|
|
/// Preload the new track's cover bytes + dominant color, then
|
|
/// atomically flip _displayedMedia / _displayedDominant. Until both
|
|
/// resolve, the screen continues to render the previous track —
|
|
/// the new title/cover/gradient appear together in one frame.
|
|
///
|
|
/// Concurrency: if a second track change arrives while the first
|
|
/// preload is still in flight, the older preload's
|
|
/// _pendingPreloadId check fails and its completion is dropped.
|
|
Future<void> _scheduleSwap(MediaItem newMedia) async {
|
|
_pendingPreloadId = newMedia.id;
|
|
|
|
// Fast path: when Prefetcher has already warmed both the cover
|
|
// bytes (file:// artUri populated via _toMediaItem's peekCached)
|
|
// AND the palette color (AlbumColorCache.peekColor returns
|
|
// non-null), we can commit synchronously. The slow async path
|
|
// exists for genuine cold-cache moments (first play of an album
|
|
// that sync hasn't seen yet); the fast path is what makes the
|
|
// common in-queue auto-advance flip the visual in lockstep with
|
|
// the audio transition instead of lagging a tick behind.
|
|
final artUri = newMedia.artUri;
|
|
final albumId = newMedia.extras?['album_id'] as String?;
|
|
if (artUri != null &&
|
|
artUri.isScheme('file') &&
|
|
albumId != null &&
|
|
albumId.isNotEmpty) {
|
|
final cachedColor = ref.read(albumColorCacheProvider).peekColor(albumId);
|
|
if (cachedColor != null) {
|
|
if (!mounted) return;
|
|
if (_pendingPreloadId != newMedia.id) return;
|
|
setState(() {
|
|
_displayedMedia = newMedia;
|
|
_displayedDominant = cachedColor;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Slow path: cover and/or color are not yet cached. Hold the
|
|
// current displayed state, preload, then atomic-commit.
|
|
//
|
|
// 1. Precache the cover image bytes so when _AlbumArt mounts with
|
|
// the new media, FileImage paints synchronously. Non-file
|
|
// artUris fall through to ServerImage which handles its own
|
|
// network load + 120ms fade — they won't snap.
|
|
if (artUri != null && artUri.isScheme('file') && context.mounted) {
|
|
try {
|
|
await precacheImage(FileImage(File.fromUri(artUri)), context);
|
|
} catch (_) {
|
|
// Decode failed (missing file, corrupt bytes) — proceed
|
|
// anyway; _AlbumArt's errorBuilder shows the slate fallback.
|
|
}
|
|
}
|
|
|
|
// 2. Wait for the dominant-color extraction so the gradient is
|
|
// populated when we swap. PaletteGenerator runs against the
|
|
// same FileImage, typically resolving within ~50ms once the
|
|
// decode completes.
|
|
Color? newDominant;
|
|
if (albumId != null && albumId.isNotEmpty) {
|
|
try {
|
|
newDominant = await ref.read(albumColorProvider(albumId).future);
|
|
} catch (_) {
|
|
// Color extraction failed — keep the previous dominant so the
|
|
// backdrop doesn't drop to obsidian on the swap.
|
|
}
|
|
}
|
|
|
|
// 3. Atomic flip. Bail if a newer swap superseded us, or the
|
|
// screen was disposed mid-preload.
|
|
if (!mounted) return;
|
|
if (_pendingPreloadId != newMedia.id) return;
|
|
setState(() {
|
|
_displayedMedia = newMedia;
|
|
if (newDominant != null) _displayedDominant = newDominant;
|
|
});
|
|
}
|
|
|
|
void _onDragStart(DragStartDetails _) {
|
|
_dragOffset = 0;
|
|
}
|
|
|
|
void _onDragUpdate(DragUpdateDetails d) {
|
|
_dragOffset += d.delta.dy;
|
|
}
|
|
|
|
void _onDragEnd(DragEndDetails d) {
|
|
// Pop when either the user has dragged > 80px down OR flicked
|
|
// downward at speed (>500 px/s). Either should feel responsive
|
|
// without dismissing on accidental drags.
|
|
final flicked = d.primaryVelocity != null && d.primaryVelocity! > 500;
|
|
if (_dragOffset > 80 || flicked) {
|
|
Navigator.of(context).maybePop();
|
|
}
|
|
_dragOffset = 0;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
|
|
// Listen for mediaItem changes and schedule a preload-then-swap.
|
|
// Build renders _displayedMedia / _displayedDominant; the live
|
|
// mediaItemProvider only drives the swap pipeline.
|
|
//
|
|
// Decision process:
|
|
// - On the first non-null mediaItem after mount, seed
|
|
// _displayedMedia immediately so the screen has something to
|
|
// paint.
|
|
// - On a track id change, kick off a preload. The new track's
|
|
// cover bytes + dominant color must both resolve before we
|
|
// flip the displayed state. The user sees the previous track
|
|
// in full until the new one is fully ready, then a clean
|
|
// atomic swap (no fade, no placeholder flash).
|
|
// - On the same track id but the artUri-bearing rebroadcast
|
|
// (audio_handler sends MediaItem twice on track change),
|
|
// refresh through the same preload pipeline so the displayed
|
|
// cover reflects the freshly-written AlbumCoverCache file.
|
|
ref.listen<AsyncValue<MediaItem?>>(mediaItemProvider, (prev, next) {
|
|
final newMedia = next.asData?.value;
|
|
if (newMedia == null) {
|
|
if (_displayedMedia != null) {
|
|
setState(() {
|
|
_displayedMedia = null;
|
|
_displayedDominant = null;
|
|
_pendingPreloadId = null;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (_displayedMedia == null) {
|
|
// First mount with a track playing — seed immediately, then
|
|
// kick the preload pipeline to update the dominant color and
|
|
// ensure the cover is decoded.
|
|
setState(() => _displayedMedia = newMedia);
|
|
_scheduleSwap(newMedia);
|
|
return;
|
|
}
|
|
final isNewTrack = newMedia.id != _displayedMedia!.id;
|
|
final coverGotPopulated =
|
|
newMedia.id == _displayedMedia!.id &&
|
|
newMedia.artUri != null &&
|
|
_displayedMedia!.artUri == null;
|
|
if (isNewTrack || coverGotPopulated) {
|
|
_scheduleSwap(newMedia);
|
|
}
|
|
});
|
|
|
|
final playback = ref.watch(playbackStateProvider).value;
|
|
final displayedMedia = _displayedMedia;
|
|
|
|
if (displayedMedia == null) {
|
|
// Either nothing playing, or the very first mount before a
|
|
// mediaItem has been emitted. The ref.listen above will seed
|
|
// _displayedMedia as soon as a non-null MediaItem arrives.
|
|
return const Scaffold(body: Center(child: Text('Nothing playing.')));
|
|
}
|
|
|
|
// Use positionProvider (just_audio's positionStream, ~200ms) for
|
|
// the seek bar so it scrubs live; PlaybackState.updatePosition
|
|
// only fires on state transitions and would leave the bar frozen
|
|
// between them.
|
|
final pos = ref.watch(positionProvider).value ?? Duration.zero;
|
|
final dur = displayedMedia.duration ?? Duration.zero;
|
|
final isPlaying = playback?.playing == true;
|
|
final shuffleOn = playback?.shuffleMode == AudioServiceShuffleMode.all;
|
|
final repeatMode = playback?.repeatMode ?? AudioServiceRepeatMode.none;
|
|
final actions = ref.read(playerActionsProvider);
|
|
final albumId = (displayedMedia.extras?['album_id'] as String?) ?? '';
|
|
|
|
// Backdrop color: render the dominant we've already committed to
|
|
// _displayedDominant. AnimatedContainer tweens between successive
|
|
// values, so the only color changes the user sees are the
|
|
// atomic-with-cover swaps from _scheduleSwap. 0.55 alpha keeps
|
|
// the gradient present without overwhelming the title/artist
|
|
// text below.
|
|
final dominant = _displayedDominant ?? fs.obsidian;
|
|
final gradientTop = dominant.withValues(alpha: 0.55);
|
|
|
|
return Scaffold(
|
|
backgroundColor: fs.obsidian,
|
|
// The whole screen accepts vertical drag for dismissal. Buttons
|
|
// and the seek slider are still tappable since gesture arena
|
|
// gives priority to their child gesture detectors.
|
|
body: GestureDetector(
|
|
onVerticalDragStart: _onDragStart,
|
|
onVerticalDragUpdate: _onDragUpdate,
|
|
onVerticalDragEnd: _onDragEnd,
|
|
behavior: HitTestBehavior.translucent,
|
|
child: AnimatedContainer(
|
|
duration: _trackChangeDuration,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [gradientTop, fs.obsidian],
|
|
stops: const [0.0, 0.7],
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_TopBar(fs: fs),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
children: [
|
|
const Spacer(),
|
|
// No AnimatedSwitcher: _displayedMedia only
|
|
// advances after the preload pipeline has the
|
|
// new cover + color ready, so a track change
|
|
// is an atomic swap. Fading would just smear a
|
|
// transition the user explicitly asked us not
|
|
// to add.
|
|
_AlbumArt(
|
|
media: displayedMedia, albumId: albumId, fs: fs),
|
|
const SizedBox(height: 28),
|
|
_TitleRow(media: displayedMedia, fs: fs),
|
|
const SizedBox(height: 4),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
displayedMedia.artist ?? '',
|
|
style: TextStyle(color: fs.ash, fontSize: 14),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if ((displayedMedia.album ?? '').isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
displayedMedia.album!,
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SecondaryControls(
|
|
fs: fs,
|
|
actions: actions,
|
|
shuffleOn: shuffleOn,
|
|
repeatMode: repeatMode,
|
|
media: displayedMedia,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_SeekRow(position: pos, duration: dur, fs: fs, ref: ref),
|
|
const SizedBox(height: 24),
|
|
_PrimaryControls(
|
|
fs: fs,
|
|
ref: ref,
|
|
isPlaying: isPlaying,
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TopBar extends StatelessWidget {
|
|
const _TopBar({required this.fs});
|
|
final FabledSwordTheme fs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: 48,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Row(children: [
|
|
IconButton(
|
|
icon: Icon(LucideIcons.chevron_down, color: fs.parchment),
|
|
tooltip: 'Close',
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: Icon(LucideIcons.list_music, color: fs.parchment),
|
|
tooltip: 'Queue',
|
|
onPressed: () => GoRouter.of(context).push('/queue'),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AlbumArt extends StatelessWidget {
|
|
const _AlbumArt({required this.media, required this.albumId, required this.fs});
|
|
final MediaItem media;
|
|
final String albumId;
|
|
final FabledSwordTheme fs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Pick the best available source. AlbumCoverCache writes a file://
|
|
// URI to media.artUri once the cover lands on disk; before then,
|
|
// fall back to fetching via ServerImage from /api/albums/<id>/cover
|
|
// (which carries the auth header and falls back gracefully on
|
|
// failure).
|
|
Widget cover;
|
|
final artUri = media.artUri;
|
|
if (artUri != null && artUri.isScheme('file')) {
|
|
cover = Image(
|
|
image: FileImage(File.fromUri(artUri)),
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(color: fs.slate),
|
|
);
|
|
} else if (albumId.isNotEmpty) {
|
|
cover = ServerImage(
|
|
url: '/api/albums/$albumId/cover',
|
|
fit: BoxFit.cover,
|
|
fallback: Container(color: fs.slate),
|
|
);
|
|
} else {
|
|
cover = Container(color: fs.slate);
|
|
}
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
|
|
child: Hero(
|
|
tag: kPlayerCoverHeroTag,
|
|
// flightShuttleBuilder ensures the in-flight Hero renders the
|
|
// destination's cover image (not the mini bar's small one)
|
|
// during the entire animation, which reads as a smooth grow
|
|
// rather than a swap mid-flight.
|
|
flightShuttleBuilder:
|
|
(_, __, ___, ____, toHeroContext) => toHeroContext.widget,
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: cover,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TitleRow extends StatelessWidget {
|
|
const _TitleRow({required this.media, required this.fs});
|
|
final MediaItem media;
|
|
final FabledSwordTheme fs;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Title-only — like + kebab moved into _SecondaryControls above
|
|
// the seek bar so they share the same row as shuffle/repeat/queue.
|
|
return Text(
|
|
media.title,
|
|
style: TextStyle(color: fs.parchment, fontSize: 22),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SeekRow extends StatelessWidget {
|
|
const _SeekRow({
|
|
required this.position,
|
|
required this.duration,
|
|
required this.fs,
|
|
required this.ref,
|
|
});
|
|
final Duration position;
|
|
final Duration duration;
|
|
final FabledSwordTheme fs;
|
|
final WidgetRef ref;
|
|
|
|
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) {
|
|
final maxMs = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);
|
|
return Column(children: [
|
|
SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
trackHeight: 3,
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
|
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
|
|
),
|
|
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())),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(_fmt(position), style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
Text(_fmt(duration), style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
],
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
class _PrimaryControls extends StatelessWidget {
|
|
const _PrimaryControls({
|
|
required this.fs,
|
|
required this.ref,
|
|
required this.isPlaying,
|
|
});
|
|
final FabledSwordTheme fs;
|
|
final WidgetRef ref;
|
|
final bool isPlaying;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
IconButton(
|
|
iconSize: 36,
|
|
icon: Icon(LucideIcons.skip_back, color: fs.parchment),
|
|
onPressed: () => ref.read(audioHandlerProvider).skipToPrevious(),
|
|
),
|
|
IconButton(
|
|
iconSize: 72,
|
|
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();
|
|
}
|
|
},
|
|
),
|
|
IconButton(
|
|
iconSize: 36,
|
|
icon: Icon(LucideIcons.skip_forward, color: fs.parchment),
|
|
onPressed: () => ref.read(audioHandlerProvider).skipToNext(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Action row sitting just above the seek bar. Holds shuffle / repeat
|
|
/// / queue plus the like + kebab that used to live in the title row,
|
|
/// so the title can sit truly centered above and this row carries
|
|
/// every per-track action in one place.
|
|
class _SecondaryControls extends StatelessWidget {
|
|
const _SecondaryControls({
|
|
required this.fs,
|
|
required this.actions,
|
|
required this.shuffleOn,
|
|
required this.repeatMode,
|
|
required this.media,
|
|
});
|
|
final FabledSwordTheme fs;
|
|
final PlayerActions actions;
|
|
final bool shuffleOn;
|
|
final AudioServiceRepeatMode repeatMode;
|
|
final MediaItem media;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
IconButton(
|
|
tooltip: shuffleOn ? 'Shuffle on' : 'Shuffle off',
|
|
icon: Icon(
|
|
LucideIcons.shuffle,
|
|
color: shuffleOn ? fs.accent : fs.ash,
|
|
),
|
|
onPressed: actions.toggleShuffle,
|
|
),
|
|
IconButton(
|
|
tooltip: switch (repeatMode) {
|
|
AudioServiceRepeatMode.none => 'Repeat off',
|
|
AudioServiceRepeatMode.one => 'Repeat one',
|
|
_ => 'Repeat all',
|
|
},
|
|
icon: Icon(
|
|
repeatMode == AudioServiceRepeatMode.one
|
|
? LucideIcons.repeat_1
|
|
: LucideIcons.repeat,
|
|
color: repeatMode == AudioServiceRepeatMode.none
|
|
? fs.ash
|
|
: fs.accent,
|
|
),
|
|
onPressed: actions.cycleRepeat,
|
|
),
|
|
IconButton(
|
|
tooltip: 'Queue',
|
|
icon: Icon(LucideIcons.list_music, color: fs.ash),
|
|
onPressed: () => GoRouter.of(context).push('/queue'),
|
|
),
|
|
LikeButton(kind: LikeKind.track, id: media.id, size: 22),
|
|
TrackActionsButton(
|
|
track: TrackRef(
|
|
id: media.id,
|
|
title: media.title,
|
|
albumId: (media.extras?['album_id'] as String?) ?? '',
|
|
albumTitle: media.album ?? '',
|
|
artistId: (media.extras?['artist_id'] as String?) ?? '',
|
|
artistName: media.artist ?? '',
|
|
durationSec: media.duration?.inSeconds ?? 0,
|
|
streamUrl: '',
|
|
),
|
|
hideQueueActions: true,
|
|
// /now-playing lives outside the ShellRoute. Pushing
|
|
// /artists/:id or /albums/:id (both shell-children) on top
|
|
// of it would make go_router try to mount a duplicate
|
|
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
|
|
// we hit before with /queue). Await our own pop fully
|
|
// before pushing the destination so go_router never sees
|
|
// both pages active in the same frame.
|
|
onNavigate: (path) async {
|
|
await Navigator.of(context).maybePop();
|
|
if (context.mounted) GoRouter.of(context).push(path);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|