From 351d47b3ff351e97932151366946452c9e3e5eaa Mon Sep 17 00:00:00 2001 From: Forrest Date: Thu, 30 Jul 2026 11:39:33 -0400 Subject: [PATCH] updates and bug fixes --- .../timbre/MainActivity.kt | 2 +- codemagic.yaml | 6 +- lib/layout/breakpoints.dart | 13 + lib/playback/playback_engine.dart | 143 ++++++++++- lib/screens/browser_screen.dart | 58 ++++- lib/screens/now_playing_screen.dart | 225 ++++++++++++++---- lib/state/favorites.dart | 21 ++ lib/state/providers.dart | 10 +- lib/widgets/block_progress_bar.dart | 5 + 9 files changed, 419 insertions(+), 64 deletions(-) rename android/app/src/main/kotlin/com/{laforrestchurch => troglodyte}/timbre/MainActivity.kt (90%) create mode 100644 lib/layout/breakpoints.dart diff --git a/android/app/src/main/kotlin/com/laforrestchurch/timbre/MainActivity.kt b/android/app/src/main/kotlin/com/troglodyte/timbre/MainActivity.kt similarity index 90% rename from android/app/src/main/kotlin/com/laforrestchurch/timbre/MainActivity.kt rename to android/app/src/main/kotlin/com/troglodyte/timbre/MainActivity.kt index 50e4ca9..76cfac0 100644 --- a/android/app/src/main/kotlin/com/laforrestchurch/timbre/MainActivity.kt +++ b/android/app/src/main/kotlin/com/troglodyte/timbre/MainActivity.kt @@ -1,4 +1,4 @@ -package com.laforrestchurch.timbre +package com.troglodyte.timbre import com.ryanheise.audioservice.AudioServiceActivity diff --git a/codemagic.yaml b/codemagic.yaml index 3052024..b8f26c9 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -29,10 +29,10 @@ workflows: # --------------------------------------------------------------------------- android: name: Android (APK + AAB) - # mac_mini_m1 is the free-tier / most widely available instance and builds + # mac_mini_m2 is the free-tier / most widely available instance and builds # Android fine. If your plan includes Linux, `linux_x2` is cheaper/faster # for Android-only builds. - instance_type: mac_mini_m1 + instance_type: mac_mini_m2 max_build_duration: 60 environment: flutter: 3.44.8 @@ -82,7 +82,7 @@ workflows: # --------------------------------------------------------------------------- ios: name: iOS (TestFlight) - instance_type: mac_mini_m1 + instance_type: mac_mini_m2 max_build_duration: 60 # Reference NAME of the App Store Connect API key integration you added in # Codemagic (Teams → Integrations → App Store Connect). Replace the value diff --git a/lib/layout/breakpoints.dart b/lib/layout/breakpoints.dart new file mode 100644 index 0000000..2673296 --- /dev/null +++ b/lib/layout/breakpoints.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +/// Width at/above which the UI switches to a wide, side-by-side layout — +/// Material's "expanded" window class, i.e. tablet landscape (iPad). Keyed off +/// width rather than raw orientation so a phone in landscape (wide but short) +/// stays on the compact layout. +const double kWideBreakpoint = 840; + +/// Whether the current window is wide enough for the side-by-side layout. +/// Uses [MediaQuery.sizeOf] so callers rebuild only when the size actually +/// changes, not on every unrelated MediaQuery update. +bool isWideLayout(BuildContext context) => + MediaQuery.sizeOf(context).width >= kWideBreakpoint; diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 1cb3804..b532b1b 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -2,12 +2,15 @@ // possible for the private callback fields below. // ignore_for_file: prefer_initializing_formals -import 'dart:io' show Platform; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show File, Platform; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; import 'package:just_audio_background/just_audio_background.dart'; +import 'package:path_provider/path_provider.dart'; import '../subsonic/models.dart'; @@ -80,10 +83,12 @@ class PlaybackController extends StateNotifier { PlaybackController({ required Uri? Function(Song) streamUriFor, required Uri? Function(Song) coverArtUriFor, + required String? Function() serverKeyGetter, required void Function(Uri artUri) onArt, required void Function(Song song) onPlay, }) : _streamUriFor = streamUriFor, _coverArtUriFor = coverArtUriFor, + _serverKeyGetter = serverKeyGetter, _onArt = onArt, _onPlay = onPlay, super(PlaybackState(supported: _audioSupported)) { @@ -91,15 +96,26 @@ class PlaybackController extends StateNotifier { _player = AudioPlayer(); _wireStreams(); } + // Persist the queue on any change (throttled) so it survives an app kill. + addListener((_) => _scheduleSave(), fireImmediately: false); } final Uri? Function(Song) _streamUriFor; final Uri? Function(Song) _coverArtUriFor; + final String? Function() _serverKeyGetter; final void Function(Uri artUri) _onArt; final void Function(Song song) _onPlay; AudioPlayer? _player; + /// Coalesces rapid state changes (a position tick every second) into at most + /// one disk write per window. + Timer? _saveTimer; + + /// Server key whose queue we've already restored (or adopted). Gates saves so + /// the empty launch state can't clobber a snapshot before restore runs. + String? _restoredKey; + static bool get _audioSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); @@ -318,8 +334,133 @@ class PlaybackController extends StateNotifier { await _player?.setLoopMode(nextMode); } + // ---- Persistence -------------------------------------------------------- + + /// Per-server queue snapshot file, alongside the playlists/downloads mirrors. + Future _snapshotFile(String key) async { + final dir = await getApplicationSupportDirectory(); + return File('${dir.path}/queue_$key.json'); + } + + /// Throttle a save: schedule one write per window, capturing the latest state + /// when it fires (so continuous position ticks don't hammer the disk). + void _scheduleSave() { + if (_saveTimer?.isActive ?? false) return; + _saveTimer = Timer(const Duration(seconds: 3), _persist); + } + + Future _persist() async { + final key = _serverKeyGetter(); + // Only persist once the active server's queue has been restored/adopted. + if (key == null || key != _restoredKey) return; + try { + final file = await _snapshotFile(key); + final tmp = File('${file.path}.tmp'); + await tmp.writeAsString(jsonEncode({ + 'queue': state.queue.map((s) => s.toJson()).toList(), + 'currentIndex': state.currentIndex, + 'positionMs': state.position.inMilliseconds, + 'shuffle': state.shuffle, + 'loop': state.loop.name, + })); + await tmp.rename(file.path); // atomic swap + } catch (_) { + // Best effort — a failed save just means a slightly staler queue. + } + } + + /// Restore the saved queue for the active server (on connect / server switch). + /// The tracks, shuffle/loop and position are loaded back into the player + /// **paused**, so the queue auto-populates without surprising the user with + /// sudden playback. Called from the [serverKeyProvider] listener; a no-op once + /// the current server has already been restored. + Future restoreForServer() async { + final key = _serverKeyGetter(); + if (key == null || key == _restoredKey) return; + + Map? snap; + try { + final file = await _snapshotFile(key); + if (await file.exists()) { + final raw = jsonDecode(await file.readAsString()); + if (raw is Map) snap = raw.cast(); + } + } catch (_) { + // Missing/corrupt snapshot is non-fatal. + } + + // Mark restored *after* reading, so saves for this server are now allowed. + _restoredKey = key; + + // Don't clobber an already-loaded queue (user started playback first). + if (state.queue.isNotEmpty || snap == null) return; + + final songs = (snap['queue'] as List? ?? const []) + .whereType() + .map((e) => Song.fromJson(e.cast())) + .toList(); + if (songs.isEmpty) return; + + final loop = LoopMode.values.firstWhere( + (m) => m.name == snap!['loop'], + orElse: () => LoopMode.off, + ); + final shuffle = snap['shuffle'] == true; + final savedIndex = (snap['currentIndex'] as int?) ?? 0; + final savedPos = Duration(milliseconds: (snap['positionMs'] as int?) ?? 0); + + final streamable = songs.where((s) => _streamUriFor(s) != null).toList(); + if (streamable.isEmpty) { + // Offline / nothing streamable: reflect the queue for the UI only. + final start = savedIndex.clamp(0, songs.length - 1); + _lastNotifiedId = songs[start].id; + state = state.copyWith( + queue: songs, + currentIndex: start, + position: savedPos, + shuffle: shuffle, + loop: loop, + ); + return; + } + + // Remap the saved index onto the streamable subset (same invariant as + // [playSongs]: state.queue == the sources loaded into the player). + final target = songs[savedIndex.clamp(0, songs.length - 1)]; + final targetIndex = streamable.indexOf(target); + final start = targetIndex >= 0 + ? targetIndex + : savedIndex.clamp(0, streamable.length - 1); + + // Suppress a spurious scrobble/history entry for the restored (not actually + // played) track, but still drive the album-art accent. + _lastNotifiedId = streamable[start].id; + final art = _coverArtUriFor(streamable[start]); + if (art != null) _onArt(art); + + state = state.copyWith( + queue: streamable, + currentIndex: start, + position: savedPos, + shuffle: shuffle, + loop: loop, + ); + + final player = _player; + if (player == null) return; // desktop: UI-only, already reflected above. + await player.setAudioSources( + streamable.map(_sourceFor).toList(), + initialIndex: start, + ); + await player.setShuffleModeEnabled(shuffle); + await player.setLoopMode(loop); + if (savedPos > Duration.zero) await player.seek(savedPos, index: start); + // Intentionally no play() — restore leaves the queue paused. + } + @override void dispose() { + _saveTimer?.cancel(); _player?.dispose(); super.dispose(); } diff --git a/lib/screens/browser_screen.dart b/lib/screens/browser_screen.dart index 3631af3..eff22e2 100644 --- a/lib/screens/browser_screen.dart +++ b/lib/screens/browser_screen.dart @@ -315,6 +315,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { Widget build(BuildContext context) { final index = ref.watch(libraryIndexProvider); final playback = ref.read(playbackProvider.notifier); + final client = ref.watch(subsonicClientProvider); final Widget body; if (index.building) { @@ -345,9 +346,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> { itemCount: index.songs.length, itemBuilder: (context, i) { final song = index.songs[i]; + final artUri = (client != null && song.coverArt != null) + ? client.coverArtUri(song.coverArt!, size: 128).toString() + : null; return BrowseRow( title: song.title ?? 'Untitled', - trailing: song.artist, + subtitle: song.artist, + artUri: artUri, downloadStatus: downloads.byId[song.id]?.status, onTap: () => playback.playSongs(index.songs, startIndex: i), onPlayNext: () => playback.playNext(song), @@ -527,6 +532,8 @@ class BrowseRow extends StatelessWidget { required this.title, this.leading, this.trailing, + this.subtitle, + this.artUri, this.onTap, this.onPlayNext, this.onAddToQueue, @@ -540,6 +547,14 @@ class BrowseRow extends StatelessWidget { final String title; final String? leading; final String? trailing; + + /// Secondary line rendered below [title] in a smaller, dimmed font (e.g. the + /// artist name on track rows). + final String? subtitle; + + /// When set, a small square album-cover thumbnail is shown at the start of + /// the row. + final String? artUri; final VoidCallback? onTap; /// When set, renders inline "play next" / "add to queue" icons (song rows). @@ -578,6 +593,23 @@ class BrowseRow extends StatelessWidget { padding: const EdgeInsets.only(left: TimbreSpacing.lg), child: Row( children: [ + if (artUri != null) ...[ + SizedBox( + width: 40, + height: 40, + child: ColoredBox( + color: TimbreColors.surface, + child: Image.network( + artUri!, + key: ValueKey(artUri), + fit: BoxFit.cover, + gaplessPlayback: true, + errorBuilder: (_, _, _) => const _AlbumArtFallback(), + ), + ), + ), + const SizedBox(width: TimbreSpacing.md), + ], if (leading != null) SizedBox( width: 28, @@ -585,11 +617,25 @@ class BrowseRow extends StatelessWidget { style: const TextStyle(color: TimbreColors.dimmed)), ), Expanded( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: TimbreColors.foreground), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: TimbreColors.foreground), + ), + if (subtitle != null) + Text( + subtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: TimbreColors.dimmed, fontSize: 12), + ), + ], ), ), if (isDone) diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f18df1c..f122c15 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -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 { 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 { ); } - 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 = [ + 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)), ], ); } diff --git a/lib/state/favorites.dart b/lib/state/favorites.dart index 3e573a8..4dd09df 100644 --- a/lib/state/favorites.dart +++ b/lib/state/favorites.dart @@ -64,6 +64,27 @@ class FavoritesController extends StateNotifier { void clear() => state = const FavoritesState(); + /// Fold a song's own server-provided star / rating into local state when it + /// isn't tracked yet. Songs surfaced outside the Favorites tab (browse, home, + /// queue) carry their `starred` flag directly, so this lets the heart light + /// up for previously-favorited tracks even when `getStarred2` hydration is + /// incomplete. Only fills gaps — it never re-adds a star the user cleared + /// this session, since callers invoke it on track change (not on toggle) and + /// it only adds when the song itself reports starred. + void seedSong(Song song) { + Set? songIds; + Map? ratings; + if (song.starred && !state.songIds.contains(song.id)) { + songIds = {...state.songIds, song.id}; + } + if (song.userRating != null && !state.ratings.containsKey(song.id)) { + ratings = {...state.ratings, song.id: song.userRating!}; + } + if (songIds != null || ratings != null) { + state = state.copyWith(songIds: songIds, ratings: ratings); + } + } + Future toggleSong(Song song) async { final client = _clientGetter(); if (client == null) return; diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 265a3fc..2caefb1 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -474,9 +474,10 @@ final playbackProvider = return client.coverArtUri(s.coverArt!, size: 512); } - return PlaybackController( + final controller = PlaybackController( streamUriFor: streamUriFor, coverArtUriFor: coverArtUriFor, + serverKeyGetter: () => ref.read(serverKeyProvider), onArt: (artUri) async { // Skip extraction entirely when the user has pinned a static accent. if (ref.read(settingsProvider).useStaticAccent) return; @@ -489,4 +490,11 @@ final playbackProvider = ref.read(subsonicClientProvider)?.scrobble(song.id).ignore(); }, ); + // Restore the persisted queue on connect / server switch, and catch the + // already-connected case at creation (the listener only fires on changes). + ref.listen(serverKeyProvider, (_, _) { + controller.restoreForServer(); + }); + controller.restoreForServer(); + return controller; }); diff --git a/lib/widgets/block_progress_bar.dart b/lib/widgets/block_progress_bar.dart index 1202443..79c1547 100644 --- a/lib/widgets/block_progress_bar.dart +++ b/lib/widgets/block_progress_bar.dart @@ -32,6 +32,11 @@ class BlockProgressBar extends StatelessWidget { return SizedBox( height: height, child: Row( + // Stretch each cell to the bar's full height. Without this the childless + // ColoredBox cells get loose (min-height 0) constraints under the Row's + // default center alignment and collapse to zero height — making the whole + // bar invisible. + crossAxisAlignment: CrossAxisAlignment.stretch, children: List.generate(cells, (i) { return Expanded( child: Padding(