feat(android): output picker foundation - mediarouter + OutputRoute
android / Build + lint + test (push) Has been cancelled

Bluetooth slice (1/5). Adds the androidx.mediarouter 1.7.0 dep,
declares BLUETOOTH_CONNECT (needed on Android 12+ to enumerate
paired BT devices by name), and lays down the OutputRoute domain
model.

OutputRoute decouples the picker UI from MediaRouter.RouteInfo
(framework class, can't be constructed in JVM tests - same
constraint we hit with LikeMediaCallback). The Protocol enum
includes UPNP/CAST/SONOS placeholders so the next slice slots in
without a data-model rename - see
docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md
for the deferred work.

Controller + ViewModel + Compose UI land in follow-up commits.
This commit is contained in:
2026-06-03 10:27:02 -04:00
parent e69a5204db
commit 0662c9d5cc
4 changed files with 76 additions and 0 deletions
+1
View File
@@ -173,6 +173,7 @@ dependencies {
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.datasource.okhttp)
implementation(libs.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
+1
View File
@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<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" />
<application
android:name=".MinstrelApplication"
@@ -0,0 +1,72 @@
package com.fabledsword.minstrel.player.output
import androidx.mediarouter.media.MediaRouter
/**
* Narrow domain model for an audio output route. Independent of
* [MediaRouter.RouteInfo] so the picker UI can preview without an
* Android framework presence (RouteInfo can't be constructed in
* JVM tests, mirroring the LikeMediaCallback constraint).
*
* The [protocol] field is a forward-compatibility hook for the
* UPnP / DLNA / Sonos / Cast slice (scope captured in
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
* THIS slice always sets `protocol = SYSTEM` — the system-route
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
*/
data class OutputRoute(
val id: String,
val name: String,
val description: String?,
val kind: Kind,
val protocol: Protocol,
val isConnected: Boolean,
) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
enum class Protocol {
/** System-managed routes — built-in speaker, wired, Bluetooth. */
SYSTEM,
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
UPNP,
/** Chromecast via the Cast SDK — reserved for a later slice. */
CAST,
/** Sonos via the Sonos extension on top of UPnP — reserved. */
SONOS,
}
companion object {
/**
* Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The
* `connectionState` proxy is good enough for the chip's
* "Connected"/"Available" subtitle.
*/
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) {
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
-> Kind.Wired
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other
}
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute(
id = route.id,
name = route.name,
description = route.description,
kind = kind,
protocol = Protocol.SYSTEM,
isConnected = connected,
)
}
}
}
+2
View File
@@ -22,6 +22,7 @@ kotlinx-coroutines = "1.9.0"
kotlinx-datetime = "0.6.1"
kotlinx-serialization-converter = "1.0.0"
media3 = "1.10.1"
mediarouter = "1.7.0"
coil = "3.0.0-rc02"
palette = "1.0.0"
timber = "5.0.1"
@@ -71,6 +72,7 @@ media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref =
media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" }