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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Before After
Before After

7
bug-fixes-8-4-26.md Normal file
View file

@ -0,0 +1,7 @@
1. In the Ipad Landscape view, The numbvers in Queue are sometimes collapsed onto a new line mainly when it is in the XXXX 1000+ range
2. When queuing all tracks that are on the server 2k+ sometimes app crashes. This also happens wwhen all the tracks are loaded and a user attempts to shufle. It freezes all audio and lags or crashes the application.
3. The Server and Settings items at the bottom of the app are not flush with tthe bottom of the screen losing out on some screen real estate.
4. Wwhen netrok device is connected the Now Playing box ion home and the mini player have flashing Album cover image
5. Network device connect disconnects when phone is locked or closed and there is currently no level of persistence on that connection.
6. The album cover color that is auto applied does not effect the remote device when connected via network. The playuing device it does but not the one controlling.
7. Genre and Year filters need to only take up 75% of screen height not the entire height of the wwindow.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

After

Width:  |  Height:  |  Size: 416 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 B

After

Width:  |  Height:  |  Size: 899 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 898 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 617 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 B

After

Width:  |  Height:  |  Size: 899 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 701 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

View file

@ -7,6 +7,7 @@ import 'package:just_audio_background/just_audio_background.dart';
import 'settings/settings_store.dart'; import 'settings/settings_store.dart';
import 'shell/app_shell.dart'; import 'shell/app_shell.dart';
import 'state/providers.dart';
import 'theme/accent.dart'; import 'theme/accent.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@ -42,6 +43,9 @@ class TimbreApp extends ConsumerWidget {
final accent = settings.useStaticAccent final accent = settings.useStaticAccent
? settings.staticAccentColor ? settings.staticAccentColor
: ref.watch(accentProvider); : 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( return MaterialApp(
title: 'Timbre', title: 'Timbre',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,

View file

@ -199,9 +199,15 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player!; final player = _player!;
player.currentIndexStream.listen((i) { player.currentIndexStream.listen((i) {
if (i == null) return; 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(); _notifyCurrent();
_maybeUpgradeToLocal(); _maybeUpgradeToLocal();
_maybeSlideWindow();
}); });
player.playerStateStream.listen((s) { player.playerStateStream.listen((s) {
state = state.copyWith(playing: s.playing); 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. /// so its own `currentIndexStream` re-emission can't re-enter the rebuild.
bool _rebuilding = false; 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() { void _notifyCurrent() {
final song = state.current; final song = state.current;
if (song == null) return; if (song == null) return;
@ -342,17 +381,109 @@ class PlaybackController extends StateNotifier<PlaybackState>
return; return;
} }
try { try {
await player.setAudioSources( // Load only a window around the start; the full queue lives in
_buildSources(ordered), // `state.queue` and the window slides as playback advances.
initialIndex: start, await _loadWindow(
queue: ordered,
center: start,
position: Duration.zero,
play: true,
); );
await player.play();
// currentIndexStream fires _notifyCurrent for the started track. // currentIndexStream fires _notifyCurrent for the started track.
} catch (e) { } catch (e) {
await _onPlayerError(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 — /// 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 /// used by the queue list so tapping a row jumps to it and keeps the current
/// (possibly shuffled) order intact. /// (possibly shuffled) order intact.
@ -368,10 +499,21 @@ class PlaybackController extends StateNotifier<PlaybackState>
return; return;
} }
try { try {
await player.seek(Duration.zero, index: index); if (index >= _windowStart && index < _windowStart + _windowLen) {
await player.play(); // Target is already loaded: a cheap seek keeps the rest of the window.
if (state.error != null) state = state.copyWith(clearError: true); await player.seek(Duration.zero, index: index - _windowStart);
// currentIndexStream fires _notifyCurrent for the jumped-to track. 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) { } catch (e) {
await _onPlayerError(e); await _onPlayerError(e);
} }
@ -390,7 +532,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
} }
final at = (current + 1).clamp(0, q.length); final at = (current + 1).clamp(0, q.length);
state = state.copyWith(queue: [...q]..insert(at, song)); 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. /// Append [song] to the end of the queue.
@ -402,8 +553,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
await playSongs([song]); await playSongs([song]);
return; 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]); 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 /// 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); final next = [...q]..removeAt(index);
if (next.isEmpty) { if (next.isEmpty) {
await _player?.clearAudioSources(); await _player?.clearAudioSources();
_windowStart = 0;
_windowLen = 0;
state = PlaybackState( state = PlaybackState(
shuffle: state.shuffle, shuffle: state.shuffle,
loop: state.loop, loop: state.loop,
@ -445,8 +606,23 @@ class PlaybackController extends StateNotifier<PlaybackState>
return; return;
} }
state = state.copyWith(queue: next); if (index < _windowStart) {
await player.removeAudioSourceAt(index); // 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). /// 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 // Mobile: just_audio adjusts its own current index and re-emits
// `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a spurious // `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a spurious
// re-scrobble when the playing track didn't actually change. // re-scrobble when the playing track didn't actually change.
state = state.copyWith(queue: next); final windowEnd = _windowStart + _windowLen;
await player.moveAudioSource(oldIndex, newIndex); 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 @override
@ -577,22 +776,16 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player; final player = _player;
if (player == null) return; if (player == null) return;
// Guard the rebuild so the `currentIndexStream` re-emission it triggers // The current song is unchanged, so [_notifyCurrent]'s id dedupe suppresses
// can't re-enter [_maybeUpgradeToLocal] and rebuild again. // a spurious re-scrobble when currentIndexStream re-fires. Only a window
_rebuilding = true; // around the current track is (re)loaded, not the whole queue. [_loadWindow]
try { // sets [_rebuilding] so its own re-emission can't re-enter this path.
// The current song is unchanged, so [_notifyCurrent]'s id dedupe await _loadWindow(
// suppresses a spurious re-scrobble when currentIndexStream re-fires. queue: target,
await player.setAudioSources( center: newIndex,
_buildSources(target), position: position,
initialIndex: newIndex, play: wasPlaying || forcePlay,
initialPosition: position, );
);
if (wasPlaying || forcePlay) await player.play();
if (state.error != null) state = state.copyWith(clearError: true);
} finally {
_rebuilding = false;
}
} }
/// Handle a load/decode failure from the player. Deliberately conservative: /// Handle a load/decode failure from the player. Deliberately conservative:
@ -672,7 +865,10 @@ class PlaybackController extends StateNotifier<PlaybackState>
final pos = player.position; final pos = player.position;
final dur = player.duration ?? state.duration; final dur = player.duration ?? state.duration;
final playing = player.playing; 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. // Skip the write (and the save tick it triggers) when nothing changed.
if (pos == state.position && if (pos == state.position &&
dur == state.duration && dur == state.duration &&
@ -815,14 +1011,19 @@ class PlaybackController extends StateNotifier<PlaybackState>
final player = _player; final player = _player;
if (player == null) return; // desktop: UI-only, already reflected above. if (player == null) return; // desktop: UI-only, already reflected above.
await player.setAudioSources( // Load only a window around the restored index (see the windowing note by
_buildSources(streamable), // [_loadWindow]); a large restored queue would otherwise crash on load.
initialIndex: start, // [_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 // 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]). // flag only — the player's own shuffle mode stays off (see [toggleShuffle]).
await player.setLoopMode(loop); await player.setLoopMode(loop);
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
// Intentionally no play() — restore leaves the queue paused. // Intentionally no play() — restore leaves the queue paused.
} }

View file

@ -116,6 +116,11 @@ class RemoteHost {
// ---- Handshake + per-client loop -------------------------------------- // ---- Handshake + per-client loop --------------------------------------
void _handleSocket(WebSocket socket) { 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(); final nonce = RemoteAuth.newNonce();
var authed = false; var authed = false;

View file

@ -48,6 +48,38 @@ class RemoteSession {
StreamController<RemoteConnStatus>.broadcast(); StreamController<RemoteConnStatus>.broadcast();
RemoteConnStatus _current = RemoteConnStatus.disconnected; 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]. /// Host-reported friendly name, available after [RemoteConnStatus.connected].
String? hostDevice; String? hostDevice;
@ -56,28 +88,89 @@ class RemoteSession {
RemoteConnStatus get currentStatus => _current; RemoteConnStatus get currentStatus => _current;
/// Dial the host and start the handshake. Status transitions are emitted on /// 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 { Future<void> connect() async {
_closed = false;
_attempt = 0;
await _dial();
}
Future<void> _dial() async {
if (_closed || _dialing) return;
_dialing = true;
_set(RemoteConnStatus.connecting); _set(RemoteConnStatus.connecting);
try { try {
final ws = await WebSocket.connect(device.wsUri.toString()) 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; _socket = ws;
_sub = ws.listen( _sub = ws.listen(
_onData, _onData,
onDone: () => _set(RemoteConnStatus.disconnected), onDone: _onDropped,
onError: (_) => _set(RemoteConnStatus.error), onError: (_) => _onDropped(),
cancelOnError: true, cancelOnError: true,
); );
} catch (_) { } 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). /// Send a control command to the host (no-op if not connected).
void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd)); void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd));
Future<void> close() async { Future<void> close() async {
_closed = true;
_retryTimer?.cancel();
_retryTimer = null;
await _sub?.cancel(); await _sub?.cancel();
_sub = null; _sub = null;
try { try {
@ -109,8 +202,15 @@ class RemoteSession {
)); ));
} else if (msg is WelcomeMessage) { } else if (msg is WelcomeMessage) {
hostDevice = msg.device; hostDevice = msg.device;
_everConnected = true;
_attempt = 0; // fresh backoff budget after a good connection
_set(RemoteConnStatus.connected); _set(RemoteConnStatus.connected);
} else if (msg is DenyMessage) { } 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); _set(RemoteConnStatus.denied);
_socket?.close().catchError((_) {}); _socket?.close().catchError((_) {});
} else if (msg is SnapshotMessage) { } else if (msg is SnapshotMessage) {

View file

@ -290,6 +290,11 @@ Future<_Picked<T>?> _showPicker<T>(
context: context, context: context,
backgroundColor: TimbreColors.background, backgroundColor: TimbreColors.background,
isScrollControlled: true, 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( builder: (ctx) => SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg), padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),

View file

@ -349,9 +349,17 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
child: Row( child: Row(
children: [ children: [
SizedBox( SizedBox(
width: 28, // Wide enough for a 4-digit index (2k+ queues); single-line
child: Text('${i + 1}', // + no-wrap so a long number can't spill onto a second line
style: const TextStyle(color: TimbreColors.dimmed)), // 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( Expanded(
child: Text( child: Text(

View file

@ -9,6 +9,7 @@ import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart'; import '../screens/settings_screen.dart';
import '../settings/settings_store.dart'; import '../settings/settings_store.dart';
import '../state/providers.dart'; import '../state/providers.dart';
import '../state/remote_providers.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/mini_player.dart'; import '../widgets/mini_player.dart';
@ -61,6 +62,10 @@ class _AppShellState extends ConsumerState<AppShell>
// PlaybackController.resyncFromPlayer). // PlaybackController.resyncFromPlayer).
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
ref.read(playbackCommandsProvider).resyncFromPlayer(); 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( bottomNavigationBar: SafeArea(
top: false, 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -243,8 +252,12 @@ class _StatusBar extends ConsumerWidget {
}; };
return Container( return Container(
height: 22,
color: TimbreColors.surface, 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( child: Row(
children: [ children: [
// Left: connection status — tap to open the server switcher. // 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); 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 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 /// the LAN to the attached device when acting as a remote, else the local
/// [PlaybackController]. Widgets read this instead of `playbackProvider.notifier`. /// [PlaybackController]. Widgets read this instead of `playbackProvider.notifier`.

View file

@ -209,10 +209,12 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
_statusSub = session.status.listen((st) { _statusSub = session.status.listen((st) {
_update((s) => s.copyWith(status: st)); _update((s) => s.copyWith(status: st));
// Any terminal status drops us back to local playback. // Only a terminal status drops us back to local playback. Transient
if (st == RemoteConnStatus.disconnected || // drops (phone locked, Wi-Fi blip) are healed inside the session, which
st == RemoteConnStatus.error || // retries and re-emits `connecting` — we stay attached (bug-fixes #5).
st == RemoteConnStatus.denied) { // `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. // Defer so we're not tearing the session down inside its own callback.
scheduleMicrotask(detach); scheduleMicrotask(detach);
} }
@ -225,6 +227,13 @@ class RemoteControlController extends StateNotifier<RemoteControlState> {
await session.connect(); 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 { Future<void> detach() async {
await _snapSub?.cancel(); await _snapSub?.cancel();
_snapSub = null; _snapSub = null;

View file

@ -48,6 +48,17 @@ class SubsonicClient {
final String _password; final String _password;
final Dio _dio; 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 apiVersion = '1.16.1';
static const String clientName = 'timbre'; static const String clientName = 'timbre';
static const String _saltAlphabet = static const String _saltAlphabet =
@ -299,8 +310,16 @@ class SubsonicClient {
}); });
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range. /// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
Uri coverArtUri(String id, {int? size}) => _uri('getCoverArt', { ///
'id': id, /// Uses the stable [_assetAuth] params (not the per-request salt) so the same
if (size != null) 'size': '${size.clamp(32, 2048)}', /// (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)}',
},
);
} }

View file

@ -159,6 +159,38 @@ void main() {
expect(host.clientCount, 0); expect(host.clientCount, 0);
}); });
test('a dropped session retries instead of detaching (bug-fixes #5)',
() async {
final session = newSession(host.port!);
await connectAuthed(session);
// Watch for the *next* time we fall back to connecting (i.e. a retry) and
// assert no terminal status slips through before it — a drop after a good
// connection must be healed, never surfaced as error/disconnected.
final retrying = session.status.firstWhere((s) {
expect(s, isNot(RemoteConnStatus.error));
expect(s, isNot(RemoteConnStatus.disconnected));
expect(s, isNot(RemoteConnStatus.denied));
return s == RemoteConnStatus.connecting;
});
// Kill the connection from under the client.
await host.stop();
await retrying;
expect(session.currentStatus, RemoteConnStatus.connecting);
});
test('a device that never answers gives up (bug-fixes #5)', () async {
// Point at a port with nothing listening: cold dials fail, and after the
// capped cold-retry budget the session goes terminal so the controller can
// fall back to local playback rather than spin forever.
final session = newSession(host.port! + 1);
final failed =
session.status.firstWhere((s) => s == RemoteConnStatus.error);
await session.connect();
await failed;
});
test('a host broadcast reaches every connected remote', () async { test('a host broadcast reaches every connected remote', () async {
final a = newSession(host.port!); final a = newSession(host.port!);
final b = newSession(host.port!); final b = newSession(host.port!);