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:
@@ -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() }
|
||||
}
|
||||
Reference in New Issue
Block a user