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

@ -1,4 +1,4 @@
package com.laforrestchurch.timbre package com.troglodyte.timbre
import com.ryanheise.audioservice.AudioServiceActivity import com.ryanheise.audioservice.AudioServiceActivity

View file

@ -29,10 +29,10 @@ workflows:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
android: android:
name: Android (APK + AAB) 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 # Android fine. If your plan includes Linux, `linux_x2` is cheaper/faster
# for Android-only builds. # for Android-only builds.
instance_type: mac_mini_m1 instance_type: mac_mini_m2
max_build_duration: 60 max_build_duration: 60
environment: environment:
flutter: 3.44.8 flutter: 3.44.8
@ -82,7 +82,7 @@ workflows:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ios: ios:
name: iOS (TestFlight) name: iOS (TestFlight)
instance_type: mac_mini_m1 instance_type: mac_mini_m2
max_build_duration: 60 max_build_duration: 60
# Reference NAME of the App Store Connect API key integration you added in # Reference NAME of the App Store Connect API key integration you added in
# Codemagic (Teams → Integrations → App Store Connect). Replace the value # Codemagic (Teams → Integrations → App Store Connect). Replace the value

View file

@ -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;

View file

@ -2,12 +2,15 @@
// possible for the private callback fields below. // possible for the private callback fields below.
// ignore_for_file: prefer_initializing_formals // 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/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart'; import 'package:just_audio_background/just_audio_background.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart'; import '../subsonic/models.dart';
@ -80,10 +83,12 @@ class PlaybackController extends StateNotifier<PlaybackState> {
PlaybackController({ PlaybackController({
required Uri? Function(Song) streamUriFor, required Uri? Function(Song) streamUriFor,
required Uri? Function(Song) coverArtUriFor, required Uri? Function(Song) coverArtUriFor,
required String? Function() serverKeyGetter,
required void Function(Uri artUri) onArt, required void Function(Uri artUri) onArt,
required void Function(Song song) onPlay, required void Function(Song song) onPlay,
}) : _streamUriFor = streamUriFor, }) : _streamUriFor = streamUriFor,
_coverArtUriFor = coverArtUriFor, _coverArtUriFor = coverArtUriFor,
_serverKeyGetter = serverKeyGetter,
_onArt = onArt, _onArt = onArt,
_onPlay = onPlay, _onPlay = onPlay,
super(PlaybackState(supported: _audioSupported)) { super(PlaybackState(supported: _audioSupported)) {
@ -91,15 +96,26 @@ class PlaybackController extends StateNotifier<PlaybackState> {
_player = AudioPlayer(); _player = AudioPlayer();
_wireStreams(); _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) _streamUriFor;
final Uri? Function(Song) _coverArtUriFor; final Uri? Function(Song) _coverArtUriFor;
final String? Function() _serverKeyGetter;
final void Function(Uri artUri) _onArt; final void Function(Uri artUri) _onArt;
final void Function(Song song) _onPlay; final void Function(Song song) _onPlay;
AudioPlayer? _player; 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 => static bool get _audioSupported =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
@ -318,8 +334,133 @@ class PlaybackController extends StateNotifier<PlaybackState> {
await _player?.setLoopMode(nextMode); await _player?.setLoopMode(nextMode);
} }
// ---- Persistence --------------------------------------------------------
/// Per-server queue snapshot file, alongside the playlists/downloads mirrors.
Future<File> _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<void> _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<void> restoreForServer() async {
final key = _serverKeyGetter();
if (key == null || key == _restoredKey) return;
Map<String, dynamic>? snap;
try {
final file = await _snapshotFile(key);
if (await file.exists()) {
final raw = jsonDecode(await file.readAsString());
if (raw is Map) snap = raw.cast<String, dynamic>();
}
} 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>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.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 @override
void dispose() { void dispose() {
_saveTimer?.cancel();
_player?.dispose(); _player?.dispose();
super.dispose(); super.dispose();
} }

View file

@ -315,6 +315,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider); final index = ref.watch(libraryIndexProvider);
final playback = ref.read(playbackProvider.notifier); final playback = ref.read(playbackProvider.notifier);
final client = ref.watch(subsonicClientProvider);
final Widget body; final Widget body;
if (index.building) { if (index.building) {
@ -345,9 +346,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
itemCount: index.songs.length, itemCount: index.songs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final song = index.songs[i]; final song = index.songs[i];
final artUri = (client != null && song.coverArt != null)
? client.coverArtUri(song.coverArt!, size: 128).toString()
: null;
return BrowseRow( return BrowseRow(
title: song.title ?? 'Untitled', title: song.title ?? 'Untitled',
trailing: song.artist, subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status, downloadStatus: downloads.byId[song.id]?.status,
onTap: () => playback.playSongs(index.songs, startIndex: i), onTap: () => playback.playSongs(index.songs, startIndex: i),
onPlayNext: () => playback.playNext(song), onPlayNext: () => playback.playNext(song),
@ -527,6 +532,8 @@ class BrowseRow extends StatelessWidget {
required this.title, required this.title,
this.leading, this.leading,
this.trailing, this.trailing,
this.subtitle,
this.artUri,
this.onTap, this.onTap,
this.onPlayNext, this.onPlayNext,
this.onAddToQueue, this.onAddToQueue,
@ -540,6 +547,14 @@ class BrowseRow extends StatelessWidget {
final String title; final String title;
final String? leading; final String? leading;
final String? trailing; 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; final VoidCallback? onTap;
/// When set, renders inline "play next" / "add to queue" icons (song rows). /// 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), padding: const EdgeInsets.only(left: TimbreSpacing.lg),
child: Row( child: Row(
children: [ 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) if (leading != null)
SizedBox( SizedBox(
width: 28, width: 28,
@ -585,11 +617,25 @@ class BrowseRow extends StatelessWidget {
style: const TextStyle(color: TimbreColors.dimmed)), style: const TextStyle(color: TimbreColors.dimmed)),
), ),
Expanded( Expanded(
child: Text( child: Column(
title, mainAxisSize: MainAxisSize.min,
maxLines: 1, crossAxisAlignment: CrossAxisAlignment.start,
overflow: TextOverflow.ellipsis, children: [
style: const TextStyle(color: TimbreColors.foreground), 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) if (isDone)

View file

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode; import 'package:just_audio/just_audio.dart' show LoopMode;
import '../downloads/download_manager.dart'; import '../downloads/download_manager.dart';
import '../layout/breakpoints.dart';
import '../playback/playback_engine.dart'; import '../playback/playback_engine.dart';
import '../settings/settings_store.dart'; import '../settings/settings_store.dart';
import '../state/providers.dart'; import '../state/providers.dart';
@ -14,9 +15,11 @@ import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart'; import 'add_to_playlist_sheet.dart';
/// Now Playing tab — album art + info strip + transport, bound to the live /// 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 /// playback engine. On compact screens the top region shows the full-size album
/// swaps to the queue when toggled, so the art keeps its original size while the /// art by default and swaps to the queue when toggled. On wide screens
/// queue still gets a usable amount of space on demand. /// (>= [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 { class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key}); const NowPlayingScreen({super.key});
@ -27,12 +30,34 @@ class NowPlayingScreen extends ConsumerStatefulWidget {
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> { class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _showQueue = false; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(playbackProvider); final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary; final accent = Theme.of(context).colorScheme.primary;
final current = state.current; 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) { if (current == null) {
return const Center( return const Center(
child: Text('Nothing playing.', child: Text('Nothing playing.',
@ -40,44 +65,98 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
); );
} }
return Padding( // Everything below the art region — shared by both layouts. The queue
padding: const EdgeInsets.fromLTRB( // toggle is deliberately excluded: it belongs only to the compact layout
TimbreSpacing.lg, // (where art and queue share one region), so it's appended separately.
TimbreSpacing.xl, final controls = <Widget>[
TimbreSpacing.lg, const _NowPlayingProgress(),
TimbreSpacing.lg, const SizedBox(height: TimbreSpacing.md),
), _InfoStrip(song: current, accent: accent),
child: Column( const SizedBox(height: TimbreSpacing.sm),
crossAxisAlignment: CrossAxisAlignment.stretch, _FavRating(song: current),
children: [ const SizedBox(height: TimbreSpacing.xs),
// The album art keeps its natural (width-bound square) size; when the _Transport(state: state, ref: ref, accent: accent),
// queue is toggled on it takes over this same region. if (!state.supported)
Expanded( const Padding(
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(), 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 padding = EdgeInsets.fromLTRB(
const SizedBox(height: TimbreSpacing.xs), TimbreSpacing.lg,
_Transport(state: state, ref: ref, accent: accent), TimbreSpacing.xl,
if (!state.supported) TimbreSpacing.lg,
const Padding( TimbreSpacing.lg,
padding: EdgeInsets.only(top: TimbreSpacing.sm), );
child: Text(
'Audio output unavailable on this platform — test on Android/iOS.', // Wide (tablet landscape): art + controls on the left, always-visible queue
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11), // 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), const SizedBox(width: TimbreSpacing.xl),
_QueueToggle( const Expanded(flex: 4, child: _QueuePanel()),
showQueue: _showQueue, ],
queueLength: state.queue.length, ),
accent: accent, );
onTap: () => setState(() => _showQueue = !_showQueue), }
),
], // 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 /// 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 /// 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 /// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
@ -333,11 +440,9 @@ class _FavRating extends ConsumerWidget {
} }
class _InfoStrip extends StatelessWidget { class _InfoStrip extends StatelessWidget {
const _InfoStrip( const _InfoStrip({required this.song, required this.accent});
{required this.song, required this.state, required this.accent});
final Song song; final Song song;
final PlaybackState state;
final Color accent; final Color accent;
@override @override
@ -357,18 +462,34 @@ class _InfoStrip extends StatelessWidget {
style: const TextStyle(color: TimbreColors.foreground)), style: const TextStyle(color: TimbreColors.foreground)),
if (album.isNotEmpty) if (album.isNotEmpty)
Text(album, style: const TextStyle(color: TimbreColors.dimmed)), 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), /// Chunky block progress bar for the Now Playing screen, sitting in the gap
Expanded(child: BlockProgressBar(progress: state.progress)), /// between the album art and the title with the elapsed / total times flanking
const SizedBox(width: TimbreSpacing.md), /// it. Isolated as its own ConsumerWidget (like the mini-player and home hero)
Text(_fmtDur(state.duration), /// so position ticks rebuild only this strip, not the art or title text.
style: const TextStyle(color: TimbreColors.dimmed)), 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)),
], ],
); );
} }

View file

@ -64,6 +64,27 @@ class FavoritesController extends StateNotifier<FavoritesState> {
void clear() => state = const FavoritesState(); 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<String>? songIds;
Map<String, int>? 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<void> toggleSong(Song song) async { Future<void> toggleSong(Song song) async {
final client = _clientGetter(); final client = _clientGetter();
if (client == null) return; if (client == null) return;

View file

@ -474,9 +474,10 @@ final playbackProvider =
return client.coverArtUri(s.coverArt!, size: 512); return client.coverArtUri(s.coverArt!, size: 512);
} }
return PlaybackController( final controller = PlaybackController(
streamUriFor: streamUriFor, streamUriFor: streamUriFor,
coverArtUriFor: coverArtUriFor, coverArtUriFor: coverArtUriFor,
serverKeyGetter: () => ref.read(serverKeyProvider),
onArt: (artUri) async { onArt: (artUri) async {
// Skip extraction entirely when the user has pinned a static accent. // Skip extraction entirely when the user has pinned a static accent.
if (ref.read(settingsProvider).useStaticAccent) return; if (ref.read(settingsProvider).useStaticAccent) return;
@ -489,4 +490,11 @@ final playbackProvider =
ref.read(subsonicClientProvider)?.scrobble(song.id).ignore(); 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<String?>(serverKeyProvider, (_, _) {
controller.restoreForServer();
});
controller.restoreForServer();
return controller;
}); });

View file

@ -32,6 +32,11 @@ class BlockProgressBar extends StatelessWidget {
return SizedBox( return SizedBox(
height: height, height: height,
child: Row( 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) { children: List.generate(cells, (i) {
return Expanded( return Expanded(
child: Padding( child: Padding(