v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77
@@ -10,6 +10,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MinstrelApplication"
|
android:name=".MinstrelApplication"
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.POST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for the cast-token endpoint. Used by the UPnP
|
||||||
|
* selection path in `OutputPickerController` to obtain a signed stream
|
||||||
|
* URL that a network speaker can fetch without the user's session
|
||||||
|
* cookie — those devices cannot carry the session, so the signed
|
||||||
|
* query string is the only way they can fetch the bytes.
|
||||||
|
*
|
||||||
|
* Endpoint: `POST /api/cast/stream-token`
|
||||||
|
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
|
||||||
|
*
|
||||||
|
* Server contract lives at `internal/api/cast_token.go`
|
||||||
|
* (commit e774097f). Field names here mirror the server's `json:`
|
||||||
|
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
|
||||||
|
* needed on the request, and `token` / `exp` / `url` map straight
|
||||||
|
* through on the response.
|
||||||
|
*/
|
||||||
|
interface CastApi {
|
||||||
|
@POST("api/cast/stream-token")
|
||||||
|
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||||
|
* the 21_600 default (6h) is long enough to play through any typical
|
||||||
|
* track without re-minting mid-playback.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class StreamTokenRequest(
|
||||||
|
val trackId: String,
|
||||||
|
val expSeconds: Int = 21_600,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||||
|
* [exp] already embedded as query params — callers pass it verbatim
|
||||||
|
* to `AVTransport.SetAVTransportURI`.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class StreamTokenResponse(
|
||||||
|
val token: String,
|
||||||
|
val exp: Long,
|
||||||
|
val url: String,
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.player.output
|
package com.fabledsword.minstrel.player.output
|
||||||
|
|
||||||
import androidx.mediarouter.media.MediaRouter
|
import androidx.mediarouter.media.MediaRouter
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrow domain model for an audio output route. Independent of
|
* Narrow domain model for an audio output route. Independent of
|
||||||
@@ -68,5 +69,35 @@ data class OutputRoute(
|
|||||||
isConnected = connected,
|
isConnected = connected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lift a discovered UPnP renderer into the picker's domain
|
||||||
|
* model. Used by the UPnP discovery controller to merge
|
||||||
|
* network speakers into the same `OutputPickerController`
|
||||||
|
* routes stream the system routes come through.
|
||||||
|
*
|
||||||
|
* `isConnected = false` because UPnP devices have no
|
||||||
|
* MediaRouter connection-state concept — they're always
|
||||||
|
* "available" on the LAN, and the picker's selected-route
|
||||||
|
* rendering handles the "currently playing" indicator.
|
||||||
|
*
|
||||||
|
* Subtitle is `manufacturer modelName` joined by a single
|
||||||
|
* space, falling back to "Network speaker" when both fields
|
||||||
|
* are blank.
|
||||||
|
*/
|
||||||
|
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
|
||||||
|
val description = listOfNotNull(
|
||||||
|
route.manufacturer.takeIf { it.isNotBlank() },
|
||||||
|
route.modelName.takeIf { it.isNotBlank() },
|
||||||
|
).joinToString(" ").ifBlank { "Network speaker" }
|
||||||
|
return OutputRoute(
|
||||||
|
id = route.id,
|
||||||
|
name = route.name,
|
||||||
|
description = description,
|
||||||
|
kind = Kind.Other,
|
||||||
|
protocol = Protocol.UPNP,
|
||||||
|
isConnected = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.player.output.upnp
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
|
||||||
|
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||||
|
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||||
|
*
|
||||||
|
* Generic UPnP only for THIS slice — Sonos-specific grouping value-adds
|
||||||
|
* (group join/leave, zone topology) live in a separate Sonos extension
|
||||||
|
* scoped in
|
||||||
|
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md.
|
||||||
|
*
|
||||||
|
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
||||||
|
* raw `<friendlyName>` straight from the device description — callers
|
||||||
|
* fall back to "Network speaker" upstream when it's blank; this class
|
||||||
|
* does not perform that substitution itself.
|
||||||
|
*/
|
||||||
|
data class UpnpRoute(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val manufacturer: String,
|
||||||
|
val modelName: String,
|
||||||
|
val avTransportControlUrl: HttpUrl,
|
||||||
|
val renderingControlUrl: HttpUrl?,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user