per output device EQ

This commit is contained in:
Boof2015
2026-07-07 16:22:30 -04:00
parent ed6d587f5f
commit c4212f81e0
13 changed files with 884 additions and 10 deletions
@@ -0,0 +1,259 @@
package expo.modules.astraaudioroute
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRouter
import android.os.Build
import android.os.Handler
import android.os.Looper
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class AstraAudioRouteModule : Module() {
private val mainHandler = Handler(Looper.getMainLooper())
private var deviceCallback: AudioDeviceCallback? = null
private var routeCallback: MediaRouter.Callback? = null
private var listening = false
override fun definition() = ModuleDefinition {
Name("AstraAudioRoute")
Events("onAudioRouteChanged")
Function("getCurrentRoute") {
snapshotRoute()
}
Function("start") {
startListening()
}
Function("stop") {
stopListening()
}
OnDestroy {
stopListening()
}
}
private fun requireContext(): Context =
appContext.reactContext ?: throw Exceptions.ReactContextLost()
private fun audioManager(): AudioManager =
requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
private fun mediaRouter(): MediaRouter =
requireContext().getSystemService(Context.MEDIA_ROUTER_SERVICE) as MediaRouter
private fun startListening() {
if (listening) return
listening = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
emitCurrentRoute()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
emitCurrentRoute()
}
}
deviceCallback = callback
audioManager().registerAudioDeviceCallback(callback, mainHandler)
}
val callback = object : MediaRouter.SimpleCallback() {
override fun onRouteSelected(router: MediaRouter, type: Int, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteUnselected(router: MediaRouter, type: Int, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteChanged(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteAdded(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
override fun onRouteRemoved(router: MediaRouter, info: MediaRouter.RouteInfo) {
emitCurrentRoute()
}
}
routeCallback = callback
mediaRouter().addCallback(MediaRouter.ROUTE_TYPE_LIVE_AUDIO, callback)
emitCurrentRoute()
}
private fun stopListening() {
if (!listening) return
listening = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
deviceCallback?.let {
try {
audioManager().unregisterAudioDeviceCallback(it)
} catch (_: Throwable) {
// Treat stop as idempotent.
}
}
}
deviceCallback = null
routeCallback?.let {
try {
mediaRouter().removeCallback(it)
} catch (_: Throwable) {
// Treat stop as idempotent.
}
}
routeCallback = null
}
private fun emitCurrentRoute() {
mainHandler.post {
sendEvent("onAudioRouteChanged", snapshotRoute())
}
}
private fun snapshotRoute(): Map<String, Any?> {
val selectedRouteName = selectedRouteName()
val device = selectOutputDevice()
val kind = device?.let { kindForType(it.type) } ?: "unknown"
val label = displayLabel(kind, device, selectedRouteName)
val key = routeKey(kind, label)
return mapOf(
"key" to key,
"label" to label,
"kind" to kind,
"nativeType" to device?.type,
"nativeId" to device?.id,
"selectedRouteName" to selectedRouteName,
"updatedAt" to System.currentTimeMillis(),
)
}
private fun selectedRouteName(): String? =
try {
mediaRouter()
.getSelectedRoute(MediaRouter.ROUTE_TYPE_LIVE_AUDIO)
?.name
?.toString()
?.trim()
?.ifEmpty { null }
} catch (_: Throwable) {
null
}
private fun selectOutputDevice(): AudioDeviceInfo? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return null
val outputs = try {
audioManager().getDevices(AudioManager.GET_DEVICES_OUTPUTS).filter { it.isSink }
} catch (_: Throwable) {
emptyList()
}
if (outputs.isEmpty()) return null
return outputs.firstOrNull { kindForType(it.type) == "bluetooth" }
?: outputs.firstOrNull { kindForType(it.type) == "wired" }
?: outputs.firstOrNull { kindForType(it.type) == "usb" }
?: outputs.firstOrNull { kindForType(it.type) == "hdmi" }
?: outputs.firstOrNull { kindForType(it.type) == "speaker" }
?: outputs.first()
}
private fun kindForType(type: Int): String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
when (type) {
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetooth"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wired"
AudioDeviceInfo.TYPE_USB_ACCESSORY,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_HEADSET -> "usb"
AudioDeviceInfo.TYPE_HDMI,
AudioDeviceInfo.TYPE_HDMI_ARC,
AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
else -> "unknown"
}
} else {
"unknown"
}
private fun displayLabel(kind: String, device: AudioDeviceInfo?, selectedRouteName: String?): String {
val productName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device?.productName?.toString()?.trim()?.ifEmpty { null }
} else {
null
}
val routeName = selectedRouteName?.takeIf { isUsefulRouteLabel(kind, it) }
val deviceName = productName?.takeIf { isUsefulRouteLabel(kind, it) }
return routeName ?: deviceName ?: defaultLabel(kind)
}
private fun defaultLabel(kind: String): String =
when (kind) {
"speaker" -> "Phone speaker"
"wired" -> "Wired headphones"
"bluetooth" -> "Bluetooth"
"usb" -> "USB audio"
"hdmi" -> "HDMI audio"
else -> "Unknown output"
}
private fun isUsefulRouteLabel(kind: String, label: String): Boolean {
val normalized = label.trim().lowercase()
if (normalized.isEmpty()) return false
val generic = setOf(
"audio",
"bluetooth",
"bluetooth audio",
"headphones",
"headset",
"phone",
"phone speaker",
"speaker",
"speakers",
"this device",
"wired headphones",
)
if (normalized in generic) return false
return kind == "bluetooth" || kind == "usb" || kind == "hdmi"
}
private fun routeKey(kind: String, label: String): String {
if (kind == "bluetooth") {
val slug = slug(label)
if (slug.isNotEmpty() && slug != "bluetooth" && slug != "bluetooth-audio") {
return "bluetooth:$slug"
}
}
return when (kind) {
"speaker" -> "speaker"
"wired" -> "wired"
"bluetooth" -> "bluetooth"
"usb" -> "usb"
"hdmi" -> "hdmi"
else -> "unknown"
}
}
private fun slug(value: String): String =
value
.trim()
.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
}