feat(android): Start Radio preserves current playback, queues after
android / Build + lint + test (push) Failing after 1m24s

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:<id>" 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.
This commit is contained in:
2026-06-02 10:09:00 -04:00
parent 1fdd785ee5
commit 516d22fc73
@@ -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:<id>"
* 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:<id>" 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 ──────────