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(
|
class PlayerController @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
private val radio: RadioController,
|
||||||
) {
|
) {
|
||||||
private val sessionToken =
|
private val sessionToken =
|
||||||
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
|
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
|
||||||
@@ -93,6 +94,46 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
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 ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
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