From 516d22fc73cc487a5ba9ca1b8d8f6ef56df690a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 10:09:00 -0400 Subject: [PATCH] feat(android): Start Radio preserves current playback, queues after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: tapping Start Radio while music plays previously reloaded the current track from position 0 because the radio seed response includes the seed at index 0 and the handler called setQueue(tracks, 0) — Flutter's playerActions.startRadio does the same. They want the current track to keep playing untouched, upcoming queue cleared, radio results appended after. PlayerController.startRadio now branches on mediaItemCount: - Empty queue: existing behavior — setQueue from index 0. - Active queue: keep currentMediaItem, removeMediaItems from currentIdx+1 to end, then addMediaItems with the radio list. When the seed is the currently-playing track (the common "Start Radio on the song I'm listening to" case), drop the seed from the appended list so it doesn't immediately repeat after the current track ends. queueRefs is updated alongside the controller so the cached TrackRef list stays consistent. Source tag "radio:" is preserved for the appended items so play_started attribution stays correct. Intentional divergence from Flutter — recorded in the docstring so future ports notice it. --- .../minstrel/player/PlayerController.kt | 48 ++++++++++++++++--- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index b7782e64..de653403 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -166,17 +166,51 @@ class PlayerController @Inject constructor( } /** - * Seed a fresh radio queue from [trackId] and start playback. - * Replaces the existing queue. The `source` tag is "radio:" - * so the server-side rotation reporter can distinguish radio - * plays from album / playlist plays. No-op on an empty server - * response; throws on transport / non-2xx for the caller to - * surface in a snackbar. + * Seed a fresh radio queue from [trackId]. The `source` tag is + * "radio:" so the server-side rotation reporter can + * distinguish radio plays from album / playlist plays. + * + * Two modes: + * - Nothing in the queue: start the radio cold from track 0 + * (the seed plays first, recommendations follow). + * - Queue already populated: do NOT interrupt — keep the current + * track playing as-is, trim every upcoming track, and append + * the radio results after the current item. If the seed is the + * currently playing track (the common "tap Start Radio on the + * song I'm listening to" case), drop it from the appended list + * so it doesn't immediately repeat. This is an intentional + * divergence from Flutter's `playerActions.startRadio`, which + * always calls `playTracks` and restarts playback from 0. + * + * No-op on an empty server response; throws on transport / non-2xx + * for the caller to surface in a snackbar. */ suspend fun startRadio(trackId: String) { + val controller = mediaController ?: return val tracks = radio.seed(trackId) if (tracks.isEmpty()) return - setQueue(tracks, initialIndex = 0, source = "radio:$trackId") + val source = "radio:$trackId" + + if (controller.mediaItemCount == 0) { + setQueue(tracks, initialIndex = 0, source = source) + return + } + + val currentIdx = controller.currentMediaItemIndex + val currentTrack = queueRefs.getOrNull(currentIdx) + // Server returns seed at index 0. Drop the duplicate when the + // seed is the currently playing track — otherwise append the + // full list so the current track is followed by the seed + + // recommendations. + val toAppend = if (currentTrack?.id == trackId) tracks.drop(1) else tracks + if (toAppend.isEmpty()) return + + val nextIdx = currentIdx + 1 + if (nextIdx < controller.mediaItemCount) { + controller.removeMediaItems(nextIdx, controller.mediaItemCount) + } + queueRefs = queueRefs.take(nextIdx) + toAppend + controller.addMediaItems(toAppend.map { it.toMediaItem(source) }) } // ── Internal: async connect + Listener-driven UI state sync ──────────