feat(android): UPnP picker foundation (UPnP slice 3/6)
android / Build + lint + test (push) Successful in 4m11s
android / Build + lint + test (push) Successful in 4m11s
UpnpRoute - narrow domain model for a discovered UPnP / DLNA
renderer. Carries the AVTransport + RenderingControl control URLs
the SOAP client uses.
CastApi - Retrofit interface for the new POST /api/cast/stream-token
endpoint (UPnP slice 2/6). Returns {token, exp, url} for the
selection path.
OutputRoute.fromUpnpRoute - companion factory that tags the route
with Protocol.UPNP. Subtitle is 'Manufacturer Model' or falls back
to 'Network speaker' when description fields are blank.
CHANGE_WIFI_MULTICAST_STATE manifest permission - install-time on
all API levels, no runtime prompt. Required for SSDP multicast
discovery.
Discovery + SOAP + integration land in follow-up commits.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
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
|
||||
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
|
||||
/**
|
||||
* Narrow domain model for an audio output route. Independent of
|
||||
@@ -68,5 +69,35 @@ data class OutputRoute(
|
||||
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