From ab62a3d118066030fd69d5a7fb68db5b4809bc73 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 22:31:45 -0400 Subject: [PATCH] fix(flutter): cancel stale background fills when queue changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy source-build commit (1ddde12) introduced a race: when the user taps play on a new track while a previous queue's _fillRemainingSources is still working, the stale fill keeps calling _player.addAudioSource() on the new player state — appending old-playlist tracks into the new queue and confusing the player into the "locked to one song" symptom. Fix: queue-generation counter. setQueueFromTracks bumps _queueGeneration first thing; the background fill captures its gen at start and aborts before any further player mutation if a newer queue has taken over. The previous play() never gets to mutate the new player state. Also resets _suppressIndexUpdates at the start of every setQueueFromTracks (defensive — covers the case where a prior backward-fill bailed on its gen check before reaching `finally`) and only releases the flag in finally if we're still the active gen. Symptoms this should resolve: - "locked to one song" after rapid play taps - Late `play() returned 73189ms` lines indicating a previous hung play() call finally resolving and stepping on current state - Player stuck in odd processingState after queue swaps --- flutter_client/lib/player/audio_handler.dart | 46 ++++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 663f9c28..bec44789 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -70,6 +70,13 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl /// is already correct, and we re-enable normal listener behavior. bool _suppressIndexUpdates = false; + /// Increments on every setQueueFromTracks call. The background + /// fill task captures the value at start and bails if the live + /// counter has moved on — without this, a stale fill will keep + /// addAudioSource'ing tracks from the previous playlist into the + /// new queue, leaving the player "locked" to a corrupted state. + int _queueGeneration = 0; + /// Volume stream for UI subscribers. Mirrors the just_audio player's /// volume directly; set via setVolume(double). Stream get volumeStream => _player.volumeStream; @@ -103,6 +110,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl if (tracks.isEmpty) return; final clampedInitial = initialIndex.clamp(0, tracks.length - 1); + // Bump the generation FIRST. Any in-flight _fillRemainingSources + // from a previous play will see the mismatch on its next gen + // check and stop calling player mutations — important so a stale + // fill doesn't append old-playlist tracks into the new queue. + final myGen = ++_queueGeneration; + // Reset suppress flag in case a prior backward-fill bailed on + // gen check before reaching its `finally`. + _suppressIndexUpdates = false; + // Populate the visible queue + current mediaItem immediately so // the player UI reflects the user's tap before any source has // been built. Source list at the just_audio layer fills in @@ -112,7 +128,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl mediaItem.add(items[clampedInitial]); debugPrint('audio_handler.setQueueFromTracks: ' - '_baseUrl="$_baseUrl" trackCount=${tracks.length}'); + '_baseUrl="$_baseUrl" trackCount=${tracks.length} gen=$myGen'); final sw = Stopwatch()..start(); // Fast path: build only the initial source so the player can @@ -120,6 +136,10 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl // in the background — addAudioSource for next/auto-advance // tracks, insertAudioSource for skipPrev tracks. final initial = await _buildAudioSource(tracks[clampedInitial]); + if (myGen != _queueGeneration) { + debugPrint('audio_handler: superseded before setAudioSources (gen=$myGen)'); + return; + } debugPrint('audio_handler: built initial source in ' '${sw.elapsedMilliseconds}ms'); sw.reset(); @@ -129,7 +149,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl debugPrint('audio_handler: setAudioSources(1) ${sw.elapsedMilliseconds}ms'); unawaited(_loadArtForCurrentItem()); - unawaited(_fillRemainingSources(tracks, clampedInitial)); + unawaited(_fillRemainingSources(tracks, clampedInitial, myGen)); } /// Background fill of the rest of the just_audio source list after @@ -138,12 +158,23 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl /// currentIndex; we suppress _onCurrentIndexChanged side effects /// for those so the mediaItem stream doesn't bounce to the wrong /// queue entry. + /// + /// `gen` is the queue generation captured when this fill started. + /// Every loop iteration checks against _queueGeneration; if a + /// newer setQueueFromTracks has run (user tapped play on something + /// else), bail immediately so we don't pollute the new queue with + /// addAudioSource calls from this stale fill. Future _fillRemainingSources( - List tracks, int initialIndex) async { + List tracks, int initialIndex, int gen) async { final sw = Stopwatch()..start(); for (var i = initialIndex + 1; i < tracks.length; i++) { + if (gen != _queueGeneration) { + debugPrint('audio_handler: forward fill aborted (gen=$gen, current=$_queueGeneration)'); + return; + } try { final src = await _buildAudioSource(tracks[i]); + if (gen != _queueGeneration) return; await _player.addAudioSource(src); } catch (e) { debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e'); @@ -157,13 +188,20 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl _suppressIndexUpdates = true; try { for (var i = 0; i < initialIndex; i++) { + if (gen != _queueGeneration) { + debugPrint('audio_handler: backward fill aborted (gen=$gen, current=$_queueGeneration)'); + return; + } final src = await _buildAudioSource(tracks[i]); + if (gen != _queueGeneration) return; await _player.insertAudioSource(i, src); } } catch (e) { debugPrint('audio_handler: backward fill failed: $e'); } finally { - _suppressIndexUpdates = false; + // Only release the flag if we're still the active gen — a + // newer setQueueFromTracks already reset it for itself. + if (gen == _queueGeneration) _suppressIndexUpdates = false; } debugPrint('audio_handler: backward fill ($initialIndex) ' '${sw.elapsedMilliseconds}ms');