updates and bug fixes

This commit is contained in:
Forrest 2026-07-30 11:39:33 -04:00
parent 8aacce5aa8
commit 351d47b3ff
9 changed files with 419 additions and 64 deletions

View file

@ -3,6 +3,7 @@ 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';
@ -14,9 +15,11 @@ 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. The top region shows the full-size album art by default and
/// swaps to the queue when toggled, so the art keeps its original size while the
/// queue still gets a usable amount of space on demand.
/// 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});
@ -27,12 +30,34 @@ class NowPlayingScreen extends ConsumerStatefulWidget {
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.',
@ -40,44 +65,98 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
TimbreSpacing.lg,
TimbreSpacing.xl,
TimbreSpacing.lg,
TimbreSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The album art keeps its natural (width-bound square) size; when the
// queue is toggled on it takes over this same region.
Expanded(
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(),
// 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 SizedBox(height: TimbreSpacing.xl),
_InfoStrip(song: current, state: state, 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(height: TimbreSpacing.sm),
_QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
accent: accent,
onTap: () => setState(() => _showQueue = !_showQueue),
),
],
),
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,
],
),
);
}
}
@ -200,6 +279,34 @@ class _QueuePanel extends ConsumerWidget {
}
}
/// 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
@ -333,11 +440,9 @@ class _FavRating extends ConsumerWidget {
}
class _InfoStrip extends StatelessWidget {
const _InfoStrip(
{required this.song, required this.state, required this.accent});
const _InfoStrip({required this.song, required this.accent});
final Song song;
final PlaybackState state;
final Color accent;
@override
@ -357,18 +462,34 @@ class _InfoStrip extends StatelessWidget {
style: const TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(height: TimbreSpacing.md),
Row(
children: [
Text(_fmtDur(state.position),
style: const TextStyle(color: TimbreColors.dimmed)),
const SizedBox(width: TimbreSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: TimbreSpacing.md),
Text(_fmtDur(state.duration),
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)),
],
);
}