614 lines
21 KiB
Dart
614 lines
21 KiB
Dart
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. Because the engine keeps `state.queue` in true play
|
||
/// order (even when shuffled), rows read top-to-bottom as history → now
|
||
/// playing → up next: already-played rows are dimmed and the list auto-scrolls
|
||
/// to keep the current track at the top as playback advances.
|
||
class _QueuePanel extends ConsumerStatefulWidget {
|
||
const _QueuePanel();
|
||
|
||
@override
|
||
ConsumerState<_QueuePanel> createState() => _QueuePanelState();
|
||
}
|
||
|
||
class _QueuePanelState extends ConsumerState<_QueuePanel> {
|
||
/// Fixed row height: a [TimbreSpacing.minTouchTarget] tall remove button plus
|
||
/// the [TimbreSpacing.xs] vertical padding above and below it. Pinning the
|
||
/// extent lets us scroll to a row by index without measuring.
|
||
static const double _rowExtent =
|
||
TimbreSpacing.minTouchTarget + TimbreSpacing.xs * 2;
|
||
|
||
final ScrollController _controller = ScrollController();
|
||
bool _didInitialScroll = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
/// Bring the row at [index] to the top of the viewport (clamped), after the
|
||
/// current frame so the list has laid out.
|
||
void _scrollToIndex(int? index) {
|
||
if (index == null || index < 0) return;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!_controller.hasClients) return;
|
||
final target =
|
||
(index * _rowExtent).clamp(0.0, _controller.position.maxScrollExtent);
|
||
_controller.animateTo(
|
||
target,
|
||
duration: const Duration(milliseconds: 300),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(playbackProvider);
|
||
final accent = Theme.of(context).colorScheme.primary;
|
||
|
||
// Follow the playing track as it advances (or as shuffle reorders things).
|
||
ref.listen<int?>(
|
||
playbackProvider.select((s) => s.currentIndex),
|
||
(_, next) => _scrollToIndex(next),
|
||
);
|
||
// Jump to the current track the first time the queue is populated.
|
||
if (!_didInitialScroll && state.queue.isNotEmpty) {
|
||
_didInitialScroll = true;
|
||
_scrollToIndex(state.currentIndex);
|
||
}
|
||
|
||
return HairlinePanel(
|
||
title: 'Queue',
|
||
trailing: '(${state.queue.length})',
|
||
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
|
||
child: ListView.builder(
|
||
controller: _controller,
|
||
padding: EdgeInsets.zero,
|
||
itemExtent: _rowExtent,
|
||
itemCount: state.queue.length,
|
||
itemBuilder: (context, i) {
|
||
final song = state.queue[i];
|
||
final current = state.currentIndex;
|
||
final isCurrent = i == current;
|
||
final isPast = current != null && i < current;
|
||
final titleColor = isCurrent
|
||
? accent
|
||
: (isPast ? TimbreColors.dimmed : TimbreColors.foreground);
|
||
return InkWell(
|
||
onTap: () => ref.read(playbackProvider.notifier).jumpTo(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: titleColor,
|
||
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')}';
|
||
}
|