major bug fixes
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 751 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 239 KiB |
7
bug-fixes-8-4-26.md
Normal 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.
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 379 B After Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 899 B |
|
Before Width: | Height: | Size: 898 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 804 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 625 B After Width: | Height: | Size: 899 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 701 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,38 @@ void main() {
|
|||
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 {
|
||||
final a = newSession(host.port!);
|
||||
final b = newSession(host.port!);
|
||||
|
|
|
|||