feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s

Bluetooth slice (2/5). Hilt singleton over androidx.mediarouter.
Owns the callback lifecycle (passive at process start, upgrades to
active when the picker sheet opens, reverts on close) and exposes
the route state as a StateFlow<RouteSnapshot> the ViewModel
projects.

Routes are sorted current-first then by Kind (Bluetooth, Wired,
BuiltIn, Other) so the active output is always at the top of the
sheet.

ViewModel + Compose UI follow in next commits.
This commit is contained in:
2026-06-03 10:30:23 -04:00
parent 0662c9d5cc
commit 087486d253
@@ -0,0 +1,135 @@
package com.fabledsword.minstrel.player.output
import android.content.Context
import androidx.mediarouter.media.MediaControlIntent
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Snapshot of the audio output route state. [current] is the live
* route audio is being delivered to. [available] is every route
* MediaRouter currently knows about, sorted current-first then by
* [OutputRoute.Kind] (Bluetooth, Wired, BuiltIn, Other).
*/
data class RouteSnapshot(
val current: OutputRoute,
val available: List<OutputRoute>,
)
/**
* Hilt-singleton facade over [MediaRouter]. Owns the callback
* lifecycle — passive discovery at process start (route list updates
* without forcing Bluetooth scans), upgrades to active discovery
* while the picker sheet is open so newly-paired devices appear
* promptly. The [routesState] StateFlow projects the current route
* + sorted available list as a [RouteSnapshot]; the picker
* ViewModel collects it.
*
* Mirrors the OutputPickerController role described in
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md.
*/
@Singleton
class OutputPickerController @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val mediaRouter = MediaRouter.getInstance(context)
private val selector = MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
.build()
private val routesStateInternal = MutableStateFlow(snapshotFromRouter())
val routesState: StateFlow<RouteSnapshot> = routesStateInternal.asStateFlow()
private val callback = object : MediaRouter.Callback() {
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
refresh()
override fun onRouteSelected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
override fun onRouteUnselected(
router: MediaRouter,
route: MediaRouter.RouteInfo,
reason: Int,
) = refresh()
}
init {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY)
}
/**
* Upgrade callback registration to active discovery — call when
* the picker sheet opens so newly-paired Bluetooth devices
* surface within a few seconds. Idempotent: re-registering with a
* new flag set replaces the prior registration in MediaRouter.
*/
fun upgradeDiscovery() {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
}
/**
* Downgrade callback registration back to passive — call when the
* picker sheet closes so we don't keep Bluetooth scanning on for
* battery cost. Same idempotent re-registration semantics.
*/
fun downgradeDiscovery() {
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_PASSIVE_DISCOVERY)
}
/**
* Select [route]. Audio routing happens at the system level; the
* MediaRouter callback's onRouteSelected fires, [refresh] re-emits,
* the chip flips to the new device.
*/
fun select(route: OutputRoute) {
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
mediaRouter.selectRoute(target)
}
private fun refresh() {
routesStateInternal.value = snapshotFromRouter()
}
private fun snapshotFromRouter(): RouteSnapshot {
val all = mediaRouter.routes
.filter { it.matchesSelector(selector) }
.map { OutputRoute.fromRouteInfo(it) }
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
return RouteSnapshot(current = current, available = sortRoutes(current, all))
}
/**
* Selected first, then Bluetooth, then Wired, then BuiltIn, then
* Other. Keeps the active output at the top + likely-wanted
* alternatives next + fallback last.
*/
private fun sortRoutes(current: OutputRoute, all: List<OutputRoute>): List<OutputRoute> {
val rank: (OutputRoute) -> Int = { route ->
when {
route.id == current.id -> 0
route.kind == OutputRoute.Kind.Bluetooth -> 1
route.kind == OutputRoute.Kind.Wired -> 2
route.kind == OutputRoute.Kind.BuiltIn -> 3
else -> 4
}
}
return all.sortedBy(rank)
}
}