diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 5c17942..add3da6 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index f9437a0..5259be3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index aa8c4ae..2163a65 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index ef2f1eb..6be862a 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index e5cd04b..e5550d3 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/icon/icon.png b/assets/icon/icon.png index 5fba308..22ce343 100644 Binary files a/assets/icon/icon.png and b/assets/icon/icon.png differ diff --git a/bug-fixes-8-4-26.md b/bug-fixes-8-4-26.md new file mode 100644 index 0000000..edc6bb7 --- /dev/null +++ b/bug-fixes-8-4-26.md @@ -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. diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 2ed9f78..e242cfa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index d4cc02c..5636f24 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index fcaae0a..7ba8c40 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index d40f0f2..848b873 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 8735156..9244754 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index d52f529..8b67ed5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 6369ad1..5fdaa67 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index fcaae0a..7ba8c40 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 9df877d..0eb0948 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 89c7edb..235b4a4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 67eef55..7af6f76 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 8c27797..c38bee4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 18bdd5a..0c1f833 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index 3e14a82..d9662ac 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 89c7edb..235b4a4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index d01d3ab..c4af51e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index a14a6ce..5c15681 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index cb05d29..d4d5d74 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 3c9bebc..8a2fdf9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 686e117..89c2057 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 6c730d0..c4182e3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/main.dart b/lib/main.dart index 7e89838..5b328b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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, diff --git a/lib/playback/playback_engine.dart b/lib/playback/playback_engine.dart index 4eceee7..4cf7c6f 100644 --- a/lib/playback/playback_engine.dart +++ b/lib/playback/playback_engine.dart @@ -199,9 +199,15 @@ class PlaybackController extends StateNotifier 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 /// 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 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 _loadWindow({ + required List 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 _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 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 } 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 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 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 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 // 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 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 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 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. } diff --git a/lib/remote/host_server.dart b/lib/remote/host_server.dart index be8d6a3..4f7c4bb 100644 --- a/lib/remote/host_server.dart +++ b/lib/remote/host_server.dart @@ -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; diff --git a/lib/remote/remote_session.dart b/lib/remote/remote_session.dart index d00a2d4..dbab439 100644 --- a/lib/remote/remote_session.dart +++ b/lib/remote/remote_session.dart @@ -48,6 +48,38 @@ class RemoteSession { StreamController.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 _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 connect() async { + _closed = false; + _attempt = 0; + await _dial(); + } + + Future _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 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) { diff --git a/lib/screens/browse_controls.dart b/lib/screens/browse_controls.dart index bb6383d..6ce45ca 100644 --- a/lib/screens/browse_controls.dart +++ b/lib/screens/browse_controls.dart @@ -290,6 +290,11 @@ Future<_Picked?> _showPicker( 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), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index b1dddbb..1dfd375 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -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( diff --git a/lib/shell/app_shell.dart b/lib/shell/app_shell.dart index 0e9f02b..a33250f 100644 --- a/lib/shell/app_shell.dart +++ b/lib/shell/app_shell.dart @@ -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 // 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 ), 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. diff --git a/lib/state/providers.dart b/lib/state/providers.dart index b1f780a..54fd82d 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -595,6 +595,33 @@ final activePlaybackProvider = Provider((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((ref) { + ref.listen( + 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`. diff --git a/lib/state/remote_providers.dart b/lib/state/remote_providers.dart index 68259ea..074cfc0 100644 --- a/lib/state/remote_providers.dart +++ b/lib/state/remote_providers.dart @@ -209,10 +209,12 @@ class RemoteControlController extends StateNotifier { _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 { 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 detach() async { await _snapSub?.cancel(); _snapSub = null; diff --git a/lib/subsonic/subsonic_client.dart b/lib/subsonic/subsonic_client.dart index c858e0b..bdcb218 100644 --- a/lib/subsonic/subsonic_client.dart +++ b/lib/subsonic/subsonic_client.dart @@ -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 _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)}', + }, + ); } diff --git a/test/remote_integration_test.dart b/test/remote_integration_test.dart index 737de0d..8e6aa31 100644 --- a/test/remote_integration_test.dart +++ b/test/remote_integration_test.dart @@ -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!);