fix android auto quirks

This commit is contained in:
Boof2015
2026-07-13 23:04:28 -04:00
parent 3ac75432a4
commit a8c1f4fd22
22 changed files with 897 additions and 27 deletions
@@ -16,3 +16,7 @@ android {
abortOnError false
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
@@ -1,6 +1,7 @@
package expo.modules.astraaudioroute
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
@@ -162,20 +163,30 @@ class AstraAudioRouteModule : Module() {
} 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()
val predicted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
try {
val mediaAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
audioManager().getAudioDevicesForAttributes(mediaAttributes).filter { it.isSink }
} catch (_: Throwable) {
emptyList()
}
} else {
emptyList()
}
return selectPredictedOutputDevice(predicted, outputs) { kindForType(it.type) }
}
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_BLUETOOTH_SCO,
AudioDeviceInfo.TYPE_BLE_HEADSET,
AudioDeviceInfo.TYPE_BLE_SPEAKER,
AudioDeviceInfo.TYPE_BLE_BROADCAST -> "bluetooth"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wired"
AudioDeviceInfo.TYPE_USB_ACCESSORY,
@@ -0,0 +1,17 @@
package expo.modules.astraaudioroute
/** Prefer Android's media-attribute prediction, then retain the legacy fallback. */
internal fun <T> selectPredictedOutputDevice(
predicted: List<T>,
connected: List<T>,
kindFor: (T) -> String,
): T? {
predicted.firstOrNull()?.let { return it }
if (connected.isEmpty()) return null
return connected.firstOrNull { kindFor(it) == "bluetooth" }
?: connected.firstOrNull { kindFor(it) == "wired" }
?: connected.firstOrNull { kindFor(it) == "usb" }
?: connected.firstOrNull { kindFor(it) == "hdmi" }
?: connected.firstOrNull { kindFor(it) == "speaker" }
?: connected.first()
}
@@ -0,0 +1,37 @@
package expo.modules.astraaudioroute
import org.junit.Assert.assertEquals
import org.junit.Test
class OutputDeviceSelectorTest {
private data class Device(val name: String, val kind: String)
@Test
fun predictedMediaDeviceWinsOverConnectedBluetoothPriority() {
val bluetooth = Device("car bluetooth", "bluetooth")
val usb = Device("android auto usb", "usb")
val selected = selectPredictedOutputDevice(
predicted = listOf(usb),
connected = listOf(bluetooth, usb),
kindFor = Device::kind,
)
assertEquals(usb, selected)
}
@Test
fun legacyFallbackKeepsExistingPriorityWithoutPrediction() {
val speaker = Device("phone", "speaker")
val usb = Device("dac", "usb")
val bluetooth = Device("headphones", "bluetooth")
val selected = selectPredictedOutputDevice(
predicted = emptyList(),
connected = listOf(speaker, usb, bluetooth),
kindFor = Device::kind,
)
assertEquals(bluetooth, selected)
}
}
+2
View File
@@ -13,6 +13,8 @@ declare class AstraAudioRouteModuleType extends NativeModule<AstraAudioRouteEven
const native = requireOptionalNativeModule<AstraAudioRouteModuleType>('AstraAudioRoute');
export const isAstraAudioRouteAvailable = native !== null;
export const AstraAudioRoute = native ?? {
addListener: () => ({ remove: () => {} }),
removeAllListeners: () => {},
+4
View File
@@ -33,3 +33,7 @@ android {
abortOnError false
}
}
dependencies {
testImplementation 'junit:junit:4.13.2'
}
@@ -79,6 +79,12 @@ class AstraScopeModule : Module() {
GainBridge.activateSmoothFor(url)
}
// Hard-prime while paused; unlike activateTrackGain this must not spend the
// first audible second gliding down from the process default (unity).
Function("primeTrackGain") { url: String ->
GainBridge.primeFor(url)
}
// Conservative temp gain applied when a transition hits an unregistered URL
// (unanalyzed track). JS keeps this at 1 while normalization is disabled.
Function("setFallbackGain") { linear: Double ->
@@ -89,6 +89,15 @@ object GainBridge {
setTarget(gains[url] ?: fallbackGain, CORRECTION_RAMP_MS)
}
/**
* Prime a paused player before play is released. There is no live signal to
* declick here, so publishing a zero-duration target guarantees the first
* processed frame is already at the requested gain.
*/
fun primeFor(url: String) {
setTarget(gains[url] ?: fallbackGain, 0)
}
/** Glide to an explicit gain (unity paths: no track / remote track / disabled). */
fun setGainSmooth(linear: Float) {
setTarget(linear, CORRECTION_RAMP_MS)
@@ -0,0 +1,26 @@
package expo.modules.astrascope
import org.junit.Assert.assertEquals
import org.junit.Test
class GainBridgeTest {
@Test
fun pausedPrimePublishesTargetWithoutCorrectionRamp() {
GainBridge.putGain("test-track", 0.25f)
GainBridge.primeFor("test-track")
assertEquals(0.25f, GainBridge.targetGain, 0.0001f)
assertEquals(0, GainBridge.rampMs)
}
@Test
fun pausedPrimeUsesFallbackForUnanalyzedTrack() {
GainBridge.fallbackGain = 0.5f
GainBridge.primeFor("missing-track")
assertEquals(0.5f, GainBridge.targetGain, 0.0001f)
assertEquals(0, GainBridge.rampMs)
}
}
+2
View File
@@ -59,6 +59,8 @@ declare class AstraScopeModuleType extends NativeModule {
setTrackGains(entries: Record<string, number>, clearExisting: boolean): void;
/** Glide to the registered gain for this URL now (mount/settings/late measurement). */
activateTrackGain(url: string): void;
/** Apply the registered gain immediately while playback is still paused. */
primeTrackGain(url: string): void;
/**
* Conservative temp gain (linear) applied when a media-item transition hits a URL
* with no registered gain (unanalyzed track). Keep at 1 while normalization is off.