major bug fixes

This commit is contained in:
Forrest 2026-08-04 19:29:58 -04:00
parent 9728906556
commit d6144c4483
39 changed files with 483 additions and 53 deletions

View file

@ -7,6 +7,7 @@ import 'package:just_audio_background/just_audio_background.dart';
import 'settings/settings_store.dart';
import 'shell/app_shell.dart';
import 'state/providers.dart';
import 'theme/accent.dart';
import 'theme/app_theme.dart';
@ -42,6 +43,9 @@ class TimbreApp extends ConsumerWidget {
final accent = settings.useStaticAccent
? settings.staticAccentColor
: ref.watch(accentProvider);
// Keep the accent synced with the remote track's art while acting as a
// remote (bug #6). Alive as long as the app is.
ref.watch(remoteAccentSyncProvider);
return MaterialApp(
title: 'Timbre',
debugShowCheckedModeBanner: false,

View file

@ -199,9 +199,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player!;
player.currentIndexStream.listen((i) {
if (i == null) return;
state = state.copyWith(currentIndex: i);
// Our own window slide/trim shifts the player's index without the
// playing track changing; ignore those emissions (logical index holds).
if (_windowBusy) return;
final logical = _windowStart + i;
if (logical < 0 || logical >= state.queue.length) return;
state = state.copyWith(currentIndex: logical);
_notifyCurrent();
_maybeUpgradeToLocal();
_maybeSlideWindow();
});
player.playerStateStream.listen((s) {
state = state.copyWith(playing: s.playing);
@ -243,6 +249,39 @@ class PlaybackController extends StateNotifier<PlaybackState>
/// so its own `currentIndexStream` re-emission can't re-enter the rebuild.
bool _rebuilding = false;
// ---- Windowed player queue ------------------------------------------
//
// The just_audio player holds only a bounded slice of `state.queue` around
// the current track, never the whole (potentially thousands-long) queue.
// Loading every source at once overran Android's ~1 MB Binder transaction
// limit (TransactionTooLargeException) and blocked the UI thread building
// thousands of MediaItems — the reported crash/freeze on "queue all" and
// shuffle-all. `state.queue` remains the full logical queue (so the UI shows
// every track, correctly numbered); only what's handed to the platform is
// bounded.
//
// [_windowStart] is the logical index (into `state.queue`) of the first
// source currently loaded, and [_windowLen] how many are loaded, so
// `logicalIndex == _windowStart + playerIndex`. The window slides via
// incremental edge edits (no reload of the playing track) as playback nears
// an edge; jumps / reorders that cross it rebuild the window outright.
/// Logical index of the first loaded source, and the number loaded.
int _windowStart = 0;
int _windowLen = 0;
/// Guards window slide/trim edits: the front insert/remove they use shifts
/// the player's own index and re-emits `currentIndexStream`, which must not
/// be mistaken for a real track change (the logical index is unchanged) or
/// re-enter the slider.
bool _windowBusy = false;
/// Keep roughly this many tracks loaded on either side of the current one;
/// begin sliding once the current track comes within [_kSlideMargin] of a
/// loaded edge that still has more queue beyond it.
static const int _kWindowRadius = 100;
static const int _kSlideMargin = 40;
void _notifyCurrent() {
final song = state.current;
if (song == null) return;
@ -342,17 +381,109 @@ class PlaybackController extends StateNotifier<PlaybackState>
return;
}
try {
await player.setAudioSources(
_buildSources(ordered),
initialIndex: start,
// Load only a window around the start; the full queue lives in
// `state.queue` and the window slides as playback advances.
await _loadWindow(
queue: ordered,
center: start,
position: Duration.zero,
play: true,
);
await player.play();
// currentIndexStream fires _notifyCurrent for the started track.
} catch (e) {
await _onPlayerError(e);
}
}
/// (Re)load exactly the window around [center] of [queue] into the player,
/// replacing whatever was loaded, and record its bounds in
/// [_windowStart]/[_windowLen]. The current track is loaded at [position],
/// so an out-of-window jump / reorder re-buffers only briefly. No-op on the
/// no-audio path beyond recording the bounds.
Future<void> _loadWindow({
required List<Song> queue,
required int center,
required Duration position,
required bool play,
}) async {
final len = queue.length;
final start = (center - _kWindowRadius).clamp(0, len).toInt();
final end = (center + _kWindowRadius + 1).clamp(0, len).toInt();
_windowStart = start;
_windowLen = end - start;
final player = _player;
if (player == null) return;
// Guard the rebuild so the `currentIndexStream` re-emission it triggers
// can't re-enter the local-upgrade / slide paths and rebuild again.
_rebuilding = true;
try {
await player.setAudioSources(
_buildSources(queue.sublist(start, end)),
initialIndex: center - start,
initialPosition: position,
);
if (play) await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
} finally {
_rebuilding = false;
}
}
/// Extend/trim the loaded window so it stays centered (within
/// [_kWindowRadius]) on the current track, using incremental edge edits that
/// leave the playing source untouched (no re-buffer). Called on every real
/// track change. Bulk range ops keep each edit to a single, index-consistent
/// `currentIndexStream` emission.
Future<void> _maybeSlideWindow() async {
final player = _player;
if (player == null || _rebuilding || _windowBusy) return;
final len = state.queue.length;
final cur = state.currentIndex;
if (cur == null || len == 0) return;
final windowEnd = _windowStart + _windowLen; // exclusive
final nearAhead = windowEnd < len && (windowEnd - cur) <= _kSlideMargin;
final nearBehind = _windowStart > 0 && (cur - _windowStart) <= _kSlideMargin;
if (!nearAhead && !nearBehind) return;
final desiredStart = (cur - _kWindowRadius).clamp(0, len).toInt();
final desiredEnd = (cur + _kWindowRadius + 1).clamp(0, len).toInt();
_windowBusy = true;
try {
// Extend ahead (append leaves the current player index untouched).
if (desiredEnd > windowEnd) {
final add = state.queue.sublist(windowEnd, desiredEnd);
await player.addAudioSources(add.map(_sourceFor).toList());
_windowLen += add.length;
}
// Extend behind (prepend shifts the player index up; logical is stable).
if (desiredStart < _windowStart) {
final pre = state.queue.sublist(desiredStart, _windowStart);
await player.insertAudioSources(0, pre.map(_sourceFor).toList());
_windowStart = desiredStart;
_windowLen += pre.length;
}
// Trim behind (removed sources sit before the current track).
if (desiredStart > _windowStart) {
final n = desiredStart - _windowStart;
await player.removeAudioSourceRange(0, n);
_windowStart += n;
_windowLen -= n;
}
// Trim ahead (removed sources sit after the current track).
if (desiredEnd < _windowStart + _windowLen) {
await player.removeAudioSourceRange(
desiredEnd - _windowStart, _windowLen);
_windowLen = desiredEnd - _windowStart;
}
} finally {
_windowBusy = false;
}
}
/// Start playing the queue entry at [index] without rebuilding the queue —
/// used by the queue list so tapping a row jumps to it and keeps the current
/// (possibly shuffled) order intact.
@ -368,10 +499,21 @@ class PlaybackController extends StateNotifier<PlaybackState>
return;
}
try {
await player.seek(Duration.zero, index: index);
await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
// currentIndexStream fires _notifyCurrent for the jumped-to track.
if (index >= _windowStart && index < _windowStart + _windowLen) {
// Target is already loaded: a cheap seek keeps the rest of the window.
await player.seek(Duration.zero, index: index - _windowStart);
await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
// currentIndexStream fires _notifyCurrent for the jumped-to track.
} else {
// Target is outside the window: rebuild it around the target (this
// re-buffers once, as any far jump would).
state = state.copyWith(
currentIndex: index, position: Duration.zero, clearError: true);
_lastNotifiedId = null;
await _loadWindow(
queue: q, center: index, position: Duration.zero, play: true);
}
} catch (e) {
await _onPlayerError(e);
}
@ -390,7 +532,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
}
final at = (current + 1).clamp(0, q.length);
state = state.copyWith(queue: [...q]..insert(at, song));
await _player?.insertAudioSource(at, _sourceFor(song));
final player = _player;
if (player == null) return;
// Insert into the player only if the slot is inside the loaded window;
// otherwise it lands in the not-yet-loaded tail and loads on the next
// slide. `at` is current+1 — always just past the (loaded) current track.
final playerAt = at - _windowStart;
if (playerAt >= 0 && playerAt <= _windowLen) {
await player.insertAudioSource(playerAt, _sourceFor(song));
_windowLen++;
}
}
/// Append [song] to the end of the queue.
@ -402,8 +553,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
await playSongs([song]);
return;
}
// The player only needs the new source if the window currently reaches the
// end of the queue; otherwise it loads when the window slides to the tail.
final tailLoaded = _windowStart + _windowLen == q.length;
state = state.copyWith(queue: [...q, song]);
await _player?.addAudioSource(_sourceFor(song));
final player = _player;
if (player == null) return;
if (tailLoaded) {
await player.addAudioSource(_sourceFor(song));
_windowLen++;
}
}
/// Remove the queue entry at [index], keeping `state.queue` and the player
@ -418,6 +577,8 @@ class PlaybackController extends StateNotifier<PlaybackState>
final next = [...q]..removeAt(index);
if (next.isEmpty) {
await _player?.clearAudioSources();
_windowStart = 0;
_windowLen = 0;
state = PlaybackState(
shuffle: state.shuffle,
loop: state.loop,
@ -445,8 +606,23 @@ class PlaybackController extends StateNotifier<PlaybackState>
return;
}
state = state.copyWith(queue: next);
await player.removeAudioSourceAt(index);
if (index < _windowStart) {
// Behind the window: nothing loaded to remove, but every loaded track's
// logical index (and the current index) shifts down by one.
_windowStart -= 1;
final cur = state.currentIndex;
state = state.copyWith(
queue: next, currentIndex: cur == null ? null : cur - 1);
} else if (index < _windowStart + _windowLen) {
// Inside the window: drop the matching player source. just_audio adjusts
// its own current index and re-emits `currentIndexStream`.
state = state.copyWith(queue: next);
_windowLen -= 1;
await player.removeAudioSourceAt(index - _windowStart);
} else {
// Ahead of the window, not yet loaded: only the logical queue changes.
state = state.copyWith(queue: next);
}
}
/// Move the queue entry from [oldIndex] to [newIndex] (drag-and-drop reorder).
@ -492,8 +668,31 @@ class PlaybackController extends StateNotifier<PlaybackState>
// Mobile: just_audio adjusts its own current index and re-emits
// `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a spurious
// re-scrobble when the playing track didn't actually change.
state = state.copyWith(queue: next);
await player.moveAudioSource(oldIndex, newIndex);
final windowEnd = _windowStart + _windowLen;
final bothInWindow = oldIndex >= _windowStart &&
oldIndex < windowEnd &&
newIndex >= _windowStart &&
newIndex < windowEnd;
if (bothInWindow) {
// Targeted move within the loaded window.
state = state.copyWith(queue: next);
await player.moveAudioSource(
oldIndex - _windowStart, newIndex - _windowStart);
} else {
// The move crosses the loaded window: recompute where the current track
// landed and rebuild the window around it (a one-off re-buffer).
final curSong = state.current;
var newCur = state.currentIndex ?? 0;
if (curSong != null) {
final idx = next.indexWhere((s) => identical(s, curSong));
if (idx >= 0) newCur = idx;
}
final pos = state.position;
final wasPlaying = state.playing;
state = state.copyWith(queue: next, currentIndex: newCur);
await _loadWindow(
queue: next, center: newCur, position: pos, play: wasPlaying);
}
}
@override
@ -577,22 +776,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player;
if (player == null) return;
// Guard the rebuild so the `currentIndexStream` re-emission it triggers
// can't re-enter [_maybeUpgradeToLocal] and rebuild again.
_rebuilding = true;
try {
// The current song is unchanged, so [_notifyCurrent]'s id dedupe
// suppresses a spurious re-scrobble when currentIndexStream re-fires.
await player.setAudioSources(
_buildSources(target),
initialIndex: newIndex,
initialPosition: position,
);
if (wasPlaying || forcePlay) await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
} finally {
_rebuilding = false;
}
// The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses
// a spurious re-scrobble when currentIndexStream re-fires. Only a window
// around the current track is (re)loaded, not the whole queue. [_loadWindow]
// sets [_rebuilding] so its own re-emission can't re-enter this path.
await _loadWindow(
queue: target,
center: newIndex,
position: position,
play: wasPlaying || forcePlay,
);
}
/// Handle a load/decode failure from the player. Deliberately conservative:
@ -672,7 +865,10 @@ class PlaybackController extends StateNotifier<PlaybackState>
final pos = player.position;
final dur = player.duration ?? state.duration;
final playing = player.playing;
final idx = player.currentIndex ?? state.currentIndex;
// player.currentIndex is a window-relative index; map it back to logical.
final idx = player.currentIndex != null
? _windowStart + player.currentIndex!
: state.currentIndex;
// Skip the write (and the save tick it triggers) when nothing changed.
if (pos == state.position &&
dur == state.duration &&
@ -815,14 +1011,19 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player;
if (player == null) return; // desktop: UI-only, already reflected above.
await player.setAudioSources(
_buildSources(streamable),
initialIndex: start,
// Load only a window around the restored index (see the windowing note by
// [_loadWindow]); a large restored queue would otherwise crash on load.
// [_loadWindow] seeks to [savedPos] via initialPosition and leaves the
// queue paused (play: false).
await _loadWindow(
queue: streamable,
center: start,
position: savedPos,
play: false,
);
// The persisted queue is already stored in play order, so shuffle is a UI
// flag only — the player's own shuffle mode stays off (see [toggleShuffle]).
await player.setLoopMode(loop);
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
// Intentionally no play() — restore leaves the queue paused.
}

View file

@ -116,6 +116,11 @@ class RemoteHost {
// ---- Handshake + per-client loop --------------------------------------
void _handleSocket(WebSocket socket) {
// Keepalive: ping idle clients so a controller that vanished without a
// close frame (crashed, walked out of Wi-Fi range) is detected and pruned
// instead of lingering as a zombie in `_clients`.
socket.pingInterval = const Duration(seconds: 15);
final nonce = RemoteAuth.newNonce();
var authed = false;

View file

@ -48,6 +48,38 @@ class RemoteSession {
StreamController<RemoteConnStatus>.broadcast();
RemoteConnStatus _current = RemoteConnStatus.disconnected;
/// True once [close] (or a hard denial / cold-connect give-up) has run —
/// stops all further reconnection.
bool _closed = false;
/// A dial is in flight; guards against overlapping [_dial] calls (e.g. a
/// backoff timer firing while [reconnectNow] also dials).
bool _dialing = false;
/// True once we've completed a handshake at least once. Distinguishes a
/// device that never answers (stale in the list — give up) from an
/// established session that later dropped (lock / Wi-Fi blip — persist).
bool _everConnected = false;
/// Consecutive failed dials since the last successful connection; indexes
/// [_kBackoff].
int _attempt = 0;
Timer? _retryTimer;
static const Duration _kDialTimeout = Duration(seconds: 8);
/// Keepalive interval. Periodic pings hold the socket open through brief
/// idle/doze windows and surface a dead peer promptly instead of leaving a
/// silently half-open connection.
static const Duration _kPingInterval = Duration(seconds: 5);
/// Reconnect backoff in seconds, held at the last value once reached.
static const List<int> _kBackoff = [0, 1, 2, 4, 8, 15];
/// How many times to retry before the *first* successful handshake before
/// giving up (a device that never answers is probably gone).
static const int _kMaxColdAttempts = 3;
/// Host-reported friendly name, available after [RemoteConnStatus.connected].
String? hostDevice;
@ -56,28 +88,89 @@ class RemoteSession {
RemoteConnStatus get currentStatus => _current;
/// Dial the host and start the handshake. Status transitions are emitted on
/// [status]; on success snapshots begin arriving on [snapshots].
/// [status]; on success snapshots begin arriving on [snapshots]. The session
/// then self-heals: a dropped socket is retried with backoff (see
/// [_scheduleRetry]) rather than surfacing a terminal status, so control
/// persists across a phone lock or Wi-Fi blip (bug-fixes #5).
Future<void> connect() async {
_closed = false;
_attempt = 0;
await _dial();
}
Future<void> _dial() async {
if (_closed || _dialing) return;
_dialing = true;
_set(RemoteConnStatus.connecting);
try {
final ws = await WebSocket.connect(device.wsUri.toString())
.timeout(const Duration(seconds: 8));
.timeout(_kDialTimeout);
if (_closed) {
unawaited(ws.close().catchError((_) {}));
return;
}
ws.pingInterval = _kPingInterval;
_socket = ws;
_sub = ws.listen(
_onData,
onDone: () => _set(RemoteConnStatus.disconnected),
onError: (_) => _set(RemoteConnStatus.error),
onDone: _onDropped,
onError: (_) => _onDropped(),
cancelOnError: true,
);
} catch (_) {
_set(RemoteConnStatus.error);
_onDropped();
} finally {
_dialing = false;
}
}
/// The socket went away (drop, dial failure, or refused). Unless we've been
/// [close]d, keep the session logically alive and retry.
void _onDropped() {
_sub = null;
_socket = null;
if (_closed) return;
_scheduleRetry();
}
void _scheduleRetry() {
// Give up only if we never got a handshake in the first place — an
// established session that dropped is retried indefinitely.
if (!_everConnected && _attempt >= _kMaxColdAttempts) {
_closed = true;
_set(RemoteConnStatus.error);
return;
}
_retryTimer?.cancel();
final i = _attempt < _kBackoff.length ? _attempt : _kBackoff.length - 1;
_attempt++;
_set(RemoteConnStatus.connecting);
_retryTimer = Timer(Duration(seconds: _kBackoff[i]), () {
_retryTimer = null;
unawaited(_dial());
});
}
/// Reset the backoff and re-dial immediately. Called when the app returns to
/// the foreground: a controller is suspended while the phone is locked, so
/// its socket to the host is usually dead on resume and we don't want to make
/// the user wait out the backoff before control is restored (bug-fixes #5).
void reconnectNow() {
if (_closed) return;
if (_current == RemoteConnStatus.connected && _socket != null) return;
_attempt = 0;
_retryTimer?.cancel();
_retryTimer = null;
unawaited(_dial());
}
/// Send a control command to the host (no-op if not connected).
void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd));
Future<void> close() async {
_closed = true;
_retryTimer?.cancel();
_retryTimer = null;
await _sub?.cancel();
_sub = null;
try {
@ -109,8 +202,15 @@ class RemoteSession {
));
} else if (msg is WelcomeMessage) {
hostDevice = msg.device;
_everConnected = true;
_attempt = 0; // fresh backoff budget after a good connection
_set(RemoteConnStatus.connected);
} else if (msg is DenyMessage) {
// A rejection (wrong account / protocol) won't fix itself — go terminal
// and stop retrying.
_closed = true;
_retryTimer?.cancel();
_retryTimer = null;
_set(RemoteConnStatus.denied);
_socket?.close().catchError((_) {});
} else if (msg is SnapshotMessage) {

View file

@ -290,6 +290,11 @@ Future<_Picked<T>?> _showPicker<T>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
// isScrollControlled removes the default height cap; constrain to 75% of
// the window so a long genre/year list never fills the entire screen.
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),

View file

@ -349,9 +349,17 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: TimbreColors.dimmed)),
// Wide enough for a 4-digit index (2k+ queues); single-line
// + no-wrap so a long number can't spill onto a second line
// inside the fixed-height row (iPad landscape, 1000+).
width: 40,
child: Text(
'${i + 1}',
maxLines: 1,
softWrap: false,
overflow: TextOverflow.clip,
style: const TextStyle(color: TimbreColors.dimmed),
),
),
Expanded(
child: Text(

View file

@ -9,6 +9,7 @@ import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../state/remote_providers.dart';
import '../theme/tokens.dart';
import '../widgets/mini_player.dart';
@ -61,6 +62,10 @@ class _AppShellState extends ConsumerState<AppShell>
// PlaybackController.resyncFromPlayer).
if (state == AppLifecycleState.resumed) {
ref.read(playbackCommandsProvider).resyncFromPlayer();
// If we were controlling a remote device, the socket likely died while
// locked/backgrounded — reconnect at once instead of waiting out the
// session's backoff (bug-fixes #5).
ref.read(remoteControlProvider.notifier).onResume();
}
}
@ -128,6 +133,10 @@ class _AppShellState extends ConsumerState<AppShell>
),
bottomNavigationBar: SafeArea(
top: false,
// Bottom inset is handled inside _StatusBar so its background paints
// flush to the screen edge (under the home indicator) instead of
// leaving a dead gap there.
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -243,8 +252,12 @@ class _StatusBar extends ConsumerWidget {
};
return Container(
height: 22,
color: TimbreColors.surface,
// Pad by the device's bottom safe-area inset so the surface color fills
// down to the physical edge while the 22px content row sits above the
// home indicator.
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
height: 22 + MediaQuery.of(context).padding.bottom,
child: Row(
children: [
// Left: connection status — tap to open the server switcher.

View file

@ -595,6 +595,33 @@ final activePlaybackProvider = Provider<PlaybackState>((ref) {
return remote ?? ref.watch(playbackProvider);
});
/// Keeps the theme accent in sync while acting as a remote (bug #6).
///
/// On the controlling device the local playback engine never loads the track,
/// so its `onArt` extraction (see [playbackControllerProvider]) never fires and
/// the accent would stay at the default. Here we re-extract the accent from the
/// mirrored remote track's cover art — the controlling device is connected to
/// the same server, so it can fetch the art itself. Selecting on `coverArt`
/// (a value-equal String) means this only fires on an actual art change, not on
/// the ~1/s snapshot churn. Watched in `main.dart` so it stays alive.
final remoteAccentSyncProvider = Provider<void>((ref) {
ref.listen<String?>(
remoteControlProvider.select(
(s) => s.isAttached ? s.remoteState?.current?.coverArt : null,
),
(prev, coverArt) async {
if (ref.read(settingsProvider).useStaticAccent) return;
if (coverArt == null) return;
final client = ref.read(subsonicClientProvider);
if (client == null) return;
final uri = client.coverArtUri(coverArt, size: 512);
final color = await extractAccent(NetworkImage(uri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color);
},
fireImmediately: true,
);
});
/// The command sink the UI should drive — a proxy that serializes commands over
/// the LAN to the attached device when acting as a remote, else the local
/// [PlaybackController]. Widgets read this instead of `playbackProvider.notifier`.

View file

@ -209,10 +209,12 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
_statusSub = session.status.listen((st) {
_update((s) => s.copyWith(status: st));
// Any terminal status drops us back to local playback.
if (st == RemoteConnStatus.disconnected ||
st == RemoteConnStatus.error ||
st == RemoteConnStatus.denied) {
// Only a terminal status drops us back to local playback. Transient
// drops (phone locked, Wi-Fi blip) are healed inside the session, which
// retries and re-emits `connecting` — we stay attached (bug-fixes #5).
// `denied` is a hard rejection; `error` here only means a device that
// never answered the initial dial.
if (st == RemoteConnStatus.denied || st == RemoteConnStatus.error) {
// Defer so we're not tearing the session down inside its own callback.
scheduleMicrotask(detach);
}
@ -225,6 +227,13 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
await session.connect();
}
/// Re-establish a dropped remote session immediately when the app returns to
/// the foreground. A controller is suspended while the phone is locked, so
/// its socket to the host is usually dead on resume; without this nudge the
/// user would wait out the reconnect backoff before control is restored
/// (bug-fixes #5). No-op when playing locally.
void onResume() => _session?.reconnectNow();
Future<void> detach() async {
await _snapSub?.cancel();
_snapSub = null;

View file

@ -48,6 +48,17 @@ class SubsonicClient {
final String _password;
final Dio _dio;
/// Auth params reused for asset URLs (cover art) so the URL is *stable*
/// across rebuilds. The per-request random salt in [_authParams] otherwise
/// makes every build produce a fresh cover-art URL, which defeats Flutter's
/// URL-keyed image cache and refetches the art on each rebuild — seen as a
/// flickering album cover, especially while mirroring a fast remote-state
/// stream (bug #4). Reusing one salt is safe under Subsonic's token scheme:
/// salt+token only prove password knowledge; the server treats neither as a
/// nonce. Computed once per client, so a credential change (new client)
/// still rotates it.
late final Map<String, String> _assetAuth = _authParams();
static const String apiVersion = '1.16.1';
static const String clientName = 'timbre';
static const String _saltAlphabet =
@ -299,8 +310,16 @@ class SubsonicClient {
});
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
Uri coverArtUri(String id, {int? size}) => _uri('getCoverArt', {
'id': id,
if (size != null) 'size': '${size.clamp(32, 2048)}',
});
///
/// Uses the stable [_assetAuth] params (not the per-request salt) so the same
/// (id, size) always yields the same URL — required for the image cache to
/// hit and the art to hold steady across rebuilds (bug #4).
Uri coverArtUri(String id, {int? size}) =>
Uri.parse('$baseUrl/rest/getCoverArt').replace(
queryParameters: {
..._assetAuth,
'id': id,
if (size != null) 'size': '${size.clamp(32, 2048)}',
},
);
}