feat(android): PlayerController playNext/enqueue/startRadio + RadioApi

Adds the three menu-driven player actions that back the TrackActions
overflow sheet. playNext/enqueue insert without disturbing playback
state; startRadio replaces the queue from GET /api/radio?seed_track=.
RadioController owns the API call so PlayerController stays Retrofit-free.

Endpoint is GET /api/radio?seed_track= (verified against
flutter_client/lib/api/endpoints/radio.dart), not the POST-with-path-
param shape originally drafted in the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:12:12 -04:00
parent f628ae4479
commit ac1832da43
4 changed files with 108 additions and 0 deletions
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.RadioResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/radio`. Mirrors the relevant slice of
* `flutter_client/lib/api/endpoints/radio.dart` (a single GET that
* returns the seeded queue). The server picks a fresh shuffle each
* invocation — clients call this once per radio start.
*/
interface RadioApi {
@GET("api/radio")
suspend fun seedTrack(
@Query("seed_track") trackId: String,
@Query("limit") limit: Int? = null,
): RadioResponseWire
}
@@ -0,0 +1,15 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.Serializable
/**
* Response envelope for `GET /api/radio?seed_track=<id>&limit=<n>`.
* The seed is returned at index 0 followed by up to limit-1
* weighted-shuffle picks scored by the server's recommendation engine.
* Mirrors Flutter's `RadioApi.seedTrack` parse — the `tracks` key is
* the only field in the envelope.
*/
@Serializable
data class RadioResponseWire(
val tracks: List<TrackWire> = emptyList(),
)
@@ -42,6 +42,7 @@ import kotlin.coroutines.resumeWithException
class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
) {
private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -93,6 +94,46 @@ class PlayerController @Inject constructor(
controller.play()
}
/**
* Insert [track] into the queue immediately after the currently
* playing item. Does NOT auto-play — playback state is preserved
* (Media3's [Player.addMediaItem] doesn't disturb the current
* window). No-op when the player isn't connected or the track has
* no streamUrl.
*/
fun playNext(track: TrackRef) {
val controller = mediaController ?: return
if (track.streamUrl.isEmpty()) return
val insertAt = (controller.currentMediaItemIndex + 1).coerceAtLeast(0)
queueRefs = queueRefs.toMutableList().apply { add(insertAt, track) }
controller.addMediaItem(insertAt, track.toMediaItem(source = null))
}
/**
* Append [track] to the end of the queue. Does NOT auto-play.
* Same streamUrl + connectivity guards as [playNext].
*/
fun enqueue(track: TrackRef) {
val controller = mediaController ?: return
if (track.streamUrl.isEmpty()) return
queueRefs = queueRefs + track
controller.addMediaItem(track.toMediaItem(source = null))
}
/**
* 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.
*/
suspend fun startRadio(trackId: String) {
val tracks = radio.seed(trackId)
if (tracks.isEmpty()) return
setQueue(tracks, initialIndex = 0, source = "radio:$trackId")
}
// ── Internal: async connect + Listener-driven UI state sync ──────────
private suspend fun connectAndObserve() {
@@ -0,0 +1,33 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.api.endpoints.RadioApi
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.TrackRef
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin facade over `/api/radio` that maps the server response into
* the domain `TrackRef` list `PlayerController.setQueue` consumes.
* Owning the mapping here keeps PlayerController free of Retrofit
* + wire-type imports; the player only sees `List<TrackRef>`.
*
* Construction follows the project convention from NetworkModule:
* per-endpoint Retrofit interfaces aren't @Provides-bound; consumers
* inject the shared `Retrofit` and call `.create()` themselves.
*/
@Singleton
class RadioController @Inject constructor(retrofit: Retrofit) {
private val api: RadioApi = retrofit.create()
/**
* Calls `GET /api/radio?seed_track=<trackId>` and maps the
* response into domain TrackRefs. Returns an empty list on a
* 2xx-but-empty server response; throws on any transport or
* non-2xx status (caller surfaces the error).
*/
suspend fun seed(trackId: String): List<TrackRef> =
api.seedTrack(trackId).tracks.map { it.toDomain() }
}