mobile-music/lib/screens/now_playing_screen.dart
2026-07-30 11:39:33 -04:00

558 lines
19 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
import '../downloads/download_manager.dart';
import '../layout/breakpoints.dart';
import '../playback/playback_engine.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/cassette_view.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
/// Now Playing tab — album art + info strip + transport, bound to the live
/// playback engine. On compact screens the top region shows the full-size album
/// art by default and swaps to the queue when toggled. On wide screens
/// (>= [kWideBreakpoint], i.e. tablet landscape) it splits into two panes: the
/// art + controls on the left and a permanently-visible queue on the right, so
/// the queue toggle is dropped.
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _showQueue = false;
@override
void initState() {
super.initState();
// Seed the already-current track once the first frame is up (the ref.listen
// below only fires on *changes*, so it would miss the launch track).
WidgetsBinding.instance.addPostFrameCallback((_) => _seedFavorites());
}
/// Reconcile the current song's own `starred` / `userRating` metadata into
/// the favorites store, so a previously-favorited track shows a filled heart
/// even when it was surfaced outside the Favorites tab. See
/// [FavoritesController.seedSong].
void _seedFavorites() {
if (!mounted) return;
final song = ref.read(playbackProvider).current;
if (song != null) ref.read(favoritesProvider.notifier).seedSong(song);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
final current = state.current;
// Re-seed the favorites store whenever the track changes.
ref.listen(playbackProvider.select((s) => s.current?.id),
(_, _) => _seedFavorites());
if (current == null) {
return const Center(
child: Text('Nothing playing.',
style: TextStyle(color: TimbreColors.dimmed)),
);
}
// Everything below the art region — shared by both layouts. The queue
// toggle is deliberately excluded: it belongs only to the compact layout
// (where art and queue share one region), so it's appended separately.
final controls = <Widget>[
const _NowPlayingProgress(),
const SizedBox(height: TimbreSpacing.md),
_InfoStrip(song: current, accent: accent),
const SizedBox(height: TimbreSpacing.sm),
_FavRating(song: current),
const SizedBox(height: TimbreSpacing.xs),
_Transport(state: state, ref: ref, accent: accent),
if (!state.supported)
const Padding(
padding: EdgeInsets.only(top: TimbreSpacing.sm),
child: Text(
'Audio output unavailable on this platform — test on Android/iOS.',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
),
),
];
const padding = EdgeInsets.fromLTRB(
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
);
// Wide (tablet landscape): art + controls on the left, always-visible queue
// on the right. The left column reuses the same scroll-view trick as the
// compact art view so the square art stays width-bound within its pane.
if (isWideLayout(context)) {
return Padding(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Art takes the leftover height and is capped to a square, so
// the controls below (esp. the transport row) stay on screen.
const Expanded(child: _FittedArt()),
const SizedBox(height: TimbreSpacing.lg),
...controls,
],
),
),
const SizedBox(width: TimbreSpacing.xl),
const Expanded(flex: 4, child: _QueuePanel()),
],
),
);
}
// Compact: art and queue share the top region, swapped by the toggle.
final queueToggle = _QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
accent: accent,
onTap: () => setState(() => _showQueue = !_showQueue),
);
return Padding(
padding: padding,
child: _showQueue
// Queue takes over the flexible region and scrolls internally.
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(child: _QueuePanel()),
const SizedBox(height: TimbreSpacing.lg),
...controls,
const SizedBox(height: TimbreSpacing.sm),
queueToggle,
],
)
// Art absorbs the leftover height, capped to a square, so the
// transport and queue toggle beneath it always stay on screen
// instead of being pushed below the fold by a full-width square.
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(child: _FittedArt()),
const SizedBox(height: TimbreSpacing.lg),
...controls,
const SizedBox(height: TimbreSpacing.sm),
queueToggle,
],
),
);
}
}
/// Text toggle that swaps the top region between album art and the queue.
/// Styled like the app's other "↻ re-roll" / "↻ refresh" affordances.
class _QueueToggle extends StatelessWidget {
const _QueueToggle({
required this.showQueue,
required this.queueLength,
required this.accent,
required this.onTap,
});
final bool showQueue;
final int queueLength;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Center(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
showQueue ? Icons.album_outlined : Icons.queue_music,
size: 16,
color: showQueue ? accent : TimbreColors.dimmed,
),
const SizedBox(width: TimbreSpacing.sm),
Text(
showQueue ? 'Album art' : 'Queue ($queueLength)',
style: TextStyle(
color: showQueue ? accent : TimbreColors.foreground,
),
),
],
),
),
),
);
}
}
/// The play queue with reorder-free remove controls. Fills whichever space the
/// top region gives it.
class _QueuePanel extends ConsumerWidget {
const _QueuePanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
return HairlinePanel(
title: 'Queue',
trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.queue.length,
itemBuilder: (context, i) {
final song = state.queue[i];
final isCurrent = i == state.currentIndex;
return InkWell(
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(state.queue, startIndex: i),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.xs,
),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: TimbreColors.dimmed)),
),
Expanded(
child: Text(
song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? accent : TimbreColors.foreground,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
),
),
),
Text(_fmt(song.duration),
style: const TextStyle(color: TimbreColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: TimbreSpacing.minTouchTarget,
height: TimbreSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: TimbreColors.dimmed),
),
),
],
),
),
);
},
),
);
}
}
/// The album-art panel sized to the largest square that fits the space it's
/// given, top-aligned so the controls beneath it always stay on screen. When
/// height is unbounded (e.g. inside a scroll view) it falls back to the full
/// available width. This is what keeps the transport from being pushed below
/// the fold when the art region would otherwise render a giant full-width
/// square.
class _FittedArt extends StatelessWidget {
const _FittedArt();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: LayoutBuilder(
builder: (context, c) {
final side =
c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth;
return SizedBox(
width: side,
height: side,
child: const _AlbumArtPanel(),
);
},
),
);
}
}
/// Album art isolated into its own `const` widget that watches only the
/// current track's cover art — so position-tick rebuilds of the parent don't
/// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
/// on screen until a genuinely new image is ready (no flashing).
class _AlbumArtPanel extends ConsumerWidget {
const _AlbumArtPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final coverArt =
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider);
final cassette =
ref.watch(settingsProvider.select((s) => s.nowPlayingCassette));
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 512).toString()
: null;
return HairlinePanel(
title: cassette ? 'Cassette' : 'Album Art',
padding: const EdgeInsets.all(TimbreSpacing.md),
child: cassette
? Center(child: CassetteView(artUri: artUri))
: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: TimbreColors.surface,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
),
),
);
}
}
/// Favorite (star) + 1–5 rating for the current track. Watches favorites only,
/// so it updates on star/rating changes independent of position ticks.
class _FavRating extends ConsumerWidget {
const _FavRating({required this.song});
final Song song;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fav = ref.watch(favoritesProvider);
final accent = Theme.of(context).colorScheme.primary;
final starred = fav.isSongStarred(song.id);
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
final downloadStatus =
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
final isDownloaded = downloadStatus == DownloadStatus.done;
final isDownloading = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return Row(
children: [
InkWell(
onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song),
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.sm),
child: Icon(
starred ? Icons.favorite : Icons.favorite_border,
color: starred ? accent : TimbreColors.dimmed,
size: 22,
),
),
),
const SizedBox(width: TimbreSpacing.md),
for (int i = 1; i <= 5; i++)
GestureDetector(
onTap: () => ref
.read(favoritesProvider.notifier)
.rateSong(song.id, i == rating ? 0 : i),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
i <= rating ? Icons.star : Icons.star_border,
size: 18,
color: i <= rating ? accent : TimbreColors.dimmed,
),
),
),
const Spacer(),
InkWell(
onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(TimbreSpacing.sm),
child: Icon(Icons.playlist_add,
size: 22, color: TimbreColors.dimmed),
),
),
InkWell(
onTap: isDownloading
? null
: () {
final dm = ref.read(downloadManagerProvider.notifier);
isDownloaded ? dm.remove(song.id) : dm.download(song);
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(TimbreSpacing.sm),
child: isDownloading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
isDownloaded
? Icons.download_done
: Icons.download_outlined,
size: 22,
color: isDownloaded ? accent : TimbreColors.dimmed,
),
),
),
],
);
}
}
class _InfoStrip extends StatelessWidget {
const _InfoStrip({required this.song, required this.accent});
final Song song;
final Color accent;
@override
Widget build(BuildContext context) {
final album = [
if (song.album != null) song.album,
if (song.year != null) '${song.year}',
].join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
],
);
}
}
/// Chunky block progress bar for the Now Playing screen, sitting in the gap
/// between the album art and the title with the elapsed / total times flanking
/// it. Isolated as its own ConsumerWidget (like the mini-player and home hero)
/// so position ticks rebuild only this strip, not the art or title text.
class _NowPlayingProgress extends ConsumerWidget {
const _NowPlayingProgress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final position = ref.watch(playbackProvider.select((s) => s.position));
final duration = ref.watch(playbackProvider.select((s) => s.duration));
final progress = ref.watch(playbackProvider.select((s) => s.progress));
return Row(
children: [
Text(_fmtDur(position),
style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: TimbreSpacing.md),
Expanded(
child: BlockProgressBar(progress: progress, cells: 28, height: 18),
),
const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(duration),
style: const TextStyle(color: TimbreColors.dimmed)),
],
);
}
}
class _Transport extends StatelessWidget {
const _Transport(
{required this.state, required this.ref, required this.accent});
final PlaybackState state;
final WidgetRef ref;
final Color accent;
@override
Widget build(BuildContext context) {
final controller = ref.read(playbackProvider.notifier);
final loopIcon = switch (state.loop) {
LoopMode.one => Icons.repeat_one,
_ => Icons.repeat,
};
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : TimbreColors.dimmed),
_btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent, size: 40),
_btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : TimbreColors.dimmed),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = TimbreColors.foreground, double size = 28}) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: color, size: size),
);
}
}
class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: TimbreColors.dimmed, size: 48),
);
}
String _fmt(int? seconds) {
if (seconds == null) return '';
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
String _fmtDur(Duration d) {
final m = d.inMinutes;
final s = d.inSeconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}