diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt new file mode 100644 index 00000000..ba829d7a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerController.kt @@ -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, +) + +/** + * 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 = 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): List { + 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) + } +}