feat(android): OutputPickerController - MediaRouter facade
android / Build + lint + test (push) Failing after 2m39s
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:
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user