diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt new file mode 100644 index 00000000..14ea0eda --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/RadioApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioWire.kt new file mode 100644 index 00000000..b9dbbf4d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/RadioWire.kt @@ -0,0 +1,15 @@ +package com.fabledsword.minstrel.models.wire + +import kotlinx.serialization.Serializable + +/** + * Response envelope for `GET /api/radio?seed_track=&limit=`. + * 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 = emptyList(), +) 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 54303daa..68aca185 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 @@ -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:" + * 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() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt new file mode 100644 index 00000000..a146dc16 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/RadioController.kt @@ -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`. + * + * 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=` 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 = + api.seedTrack(trackId).tracks.map { it.toDomain() } +}