diff --git a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt index 3c95f75..32adac3 100644 --- a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt +++ b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/AstraAudioRouteModule.kt @@ -195,6 +195,7 @@ class AstraAudioRouteModule : Module() { AudioDeviceInfo.TYPE_HDMI, AudioDeviceInfo.TYPE_HDMI_ARC, AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi" + AudioDeviceInfo.TYPE_REMOTE_SUBMIX -> "remote" AudioDeviceInfo.TYPE_BUILTIN_EARPIECE, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker" else -> "unknown" @@ -232,6 +233,7 @@ class AstraAudioRouteModule : Module() { "bluetooth" -> "Bluetooth" "usb" -> "USB audio" "hdmi" -> "HDMI audio" + "remote" -> "Remote audio" else -> "Unknown output" } diff --git a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt index 128fc38..e0280bd 100644 --- a/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt +++ b/modules/astra-audio-route/android/src/main/java/expo/modules/astraaudioroute/OutputDeviceSelector.kt @@ -10,7 +10,8 @@ internal fun selectPredictedOutputDevice( ): T? { predicted.firstOrNull()?.let { return it } if (connected.isEmpty()) return null - return connected.firstOrNull { kindFor(it) == "bluetooth" } + return connected.firstOrNull { kindFor(it) == "remote" } + ?: connected.firstOrNull { kindFor(it) == "bluetooth" } ?: connected.firstOrNull { kindFor(it) == "wired" } ?: connected.firstOrNull { kindFor(it) == "usb" } ?: connected.firstOrNull { kindFor(it) == "hdmi" } @@ -49,6 +50,7 @@ private fun addressToken(address: String?): String? { internal fun buildOutputRouteKey(kind: String, label: String, address: String?): String { if (kind == "speaker") return "speaker" if (kind == "wired") return "wired" + if (kind == "remote") return "remote" if (kind !in setOf("bluetooth", "usb", "hdmi")) return "unknown" addressToken(address)?.let { return "$kind:id:$it" } diff --git a/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt b/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt index e3ce114..7bf7fe7 100644 --- a/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt +++ b/modules/astra-audio-route/android/src/test/java/expo/modules/astraaudioroute/OutputDeviceSelectorTest.kt @@ -37,6 +37,20 @@ class OutputDeviceSelectorTest { assertEquals(bluetooth, selected) } + @Test + fun legacyFallbackPrefersAnActiveRemoteMixForProjectedMedia() { + val bluetooth = Device("car bluetooth", "bluetooth") + val remote = Device("android auto", "remote") + + val selected = selectPredictedOutputDevice( + predicted = emptyList(), + connected = listOf(bluetooth, remote), + kindFor = Device::kind, + ) + + assertEquals(remote, selected) + } + @Test fun externalAddressProducesStablePrivacySafeKey() { val first = buildOutputRouteKey("bluetooth", "Sony WH-1000XM5", "AA:BB:CC:DD:EE:FF") @@ -63,6 +77,7 @@ class OutputDeviceSelectorTest { assertEquals("bluetooth", buildOutputRouteKey("bluetooth", "Bluetooth audio", null)) assertEquals("usb", buildOutputRouteKey("usb", "USB audio", null)) assertEquals("hdmi", buildOutputRouteKey("hdmi", "HDMI audio", null)) + assertEquals("remote", buildOutputRouteKey("remote", "Remote audio", null)) assertEquals("unknown", buildOutputRouteKey("unknown", "Unknown output", null)) } } diff --git a/src/audio/audioProcessingStartup.ts b/src/audio/audioProcessingStartup.ts index 8de4532..4f6561b 100644 --- a/src/audio/audioProcessingStartup.ts +++ b/src/audio/audioProcessingStartup.ts @@ -105,6 +105,8 @@ const coordinator = new DspStartupCoordinator< console.info('[dsp-startup] base-applied', { at: Date.now(), routeKey: route.key, + routeKind: route.kind, + nativeType: route.nativeType, eqEnabled: eq.enabled, preampDb: eq.preamp, normalizationEnabled: settings.enabled, @@ -134,6 +136,8 @@ const coordinator = new DspStartupCoordinator< console.info('[dsp-startup] target-applied', { at: Date.now(), routeKey: route.key, + routeKind: route.kind, + nativeType: route.nativeType, gainSource: resolved.source, activation: target.activation, hasTarget: Boolean(target.url), diff --git a/src/audio/eqDevicePresets.ts b/src/audio/eqDevicePresets.ts index a916b83..34f931a 100644 --- a/src/audio/eqDevicePresets.ts +++ b/src/audio/eqDevicePresets.ts @@ -25,6 +25,7 @@ function isRouteKind(value: unknown): value is AudioOutputRouteKind { value === 'bluetooth' || value === 'usb' || value === 'hdmi' || + value === 'remote' || value === 'unknown' ); } diff --git a/src/audio/eqRouteProfiles.test.mts b/src/audio/eqRouteProfiles.test.mts index 94d240e..466c500 100644 --- a/src/audio/eqRouteProfiles.test.mts +++ b/src/audio/eqRouteProfiles.test.mts @@ -4,6 +4,7 @@ import type { AudioOutputRoute, EQBand } from '../types/audio.ts'; import { buildAudioOutputRouteKey, createEQRouteProfile, + isAudioOutputRouteUsable, normalizeAudioOutputRoute, parseEQRouteProfilesJson, restoreEQRouteProfile, @@ -55,10 +56,16 @@ test('normalizes named external routes and generic class routes', () => { assert.equal(normalizeAudioOutputRoute({ kind: 'usb', label: 'USB DAC' })?.key, 'usb:name:usb-dac'); assert.equal(normalizeAudioOutputRoute({ kind: 'usb', label: 'USB audio' })?.key, 'usb'); assert.equal(normalizeAudioOutputRoute({ kind: 'hdmi', label: 'Living Room TV' })?.key, 'hdmi:name:living-room-tv'); + assert.equal(normalizeAudioOutputRoute({ kind: 'remote', label: 'Remote audio' })?.key, 'remote'); assert.equal(normalizeAudioOutputRoute({ kind: 'speaker', label: 'Pixel speaker' })?.key, 'speaker'); assert.equal(normalizeAudioOutputRoute({ kind: 'nonsense', label: '' })?.key, 'unknown'); }); +test('accepts concrete unclassified Android outputs without accepting a missing route', () => { + assert.equal(isAudioOutputRouteUsable(route({ kind: 'unknown', nativeType: 25 })), true); + assert.equal(isAudioOutputRouteUsable(route({ kind: 'unknown', nativeType: null })), false); +}); + test('recovers from corrupt route profile storage', () => { assert.deepEqual(parseEQRouteProfilesJson('{not-json', nextId), {}); assert.deepEqual( diff --git a/src/audio/eqRouteProfiles.ts b/src/audio/eqRouteProfiles.ts index ac8c63b..7270f05 100644 --- a/src/audio/eqRouteProfiles.ts +++ b/src/audio/eqRouteProfiles.ts @@ -49,6 +49,7 @@ function isRouteKind(value: unknown): value is AudioOutputRouteKind { value === 'bluetooth' || value === 'usb' || value === 'hdmi' || + value === 'remote' || value === 'unknown' ); } @@ -84,6 +85,8 @@ function defaultRouteLabel(kind: AudioOutputRouteKind): string { return 'USB audio'; case 'hdmi': return 'HDMI audio'; + case 'remote': + return 'Remote audio'; default: return 'Unknown output'; } @@ -124,6 +127,8 @@ export function buildAudioOutputRouteKey(kind: AudioOutputRouteKind, label: stri return 'usb'; case 'hdmi': return 'hdmi'; + case 'remote': + return 'remote'; default: return 'unknown'; } @@ -148,6 +153,11 @@ export function normalizeAudioOutputRoute(value: unknown): AudioOutputRoute | nu }; } +/** A known kind or a concrete native type is enough to apply the loaded EQ safely. */ +export function isAudioOutputRouteUsable(route: AudioOutputRoute): boolean { + return route.kind !== 'unknown' || route.nativeType != null; +} + function normalizeBands(value: unknown, createId: () => string): EQBand[] | null { if (!Array.isArray(value) || value.length === 0) return null; return value diff --git a/src/audio/eqRouteSync.ts b/src/audio/eqRouteSync.ts index a421dcc..45622c2 100644 --- a/src/audio/eqRouteSync.ts +++ b/src/audio/eqRouteSync.ts @@ -4,6 +4,7 @@ import { } from '../../modules/astra-audio-route'; import { useEQStore } from '@/stores/eqStore'; import type { AudioOutputRoute } from '@/types/audio'; +import { isAudioOutputRouteUsable } from '@/audio/eqRouteProfiles'; type Subscription = { remove: () => void }; @@ -30,7 +31,19 @@ export async function refreshEQRouteForPlayback(): Promise { await useEQStore.getState().load(); const route = AstraAudioRoute.getCurrentRoute(); if (!route) throw new Error('Current media output route unavailable'); - if (route.kind === 'unknown') throw new Error('Current media output route unresolved'); + if (!isAudioOutputRouteUsable(route)) { + throw new Error('Current media output route unresolved'); + } + if (route.kind === 'unknown') { + // A real but newer Android route type is still safe: retain/reassert the + // loaded EQ state instead of permanently blocking playback. Android Auto + // used to reach this path because TYPE_REMOTE_SUBMIX was not classified. + console.warn('[eq-route] unclassified native output; retaining current EQ', { + nativeType: route.nativeType, + nativeId: route.nativeId, + label: route.label, + }); + } await useEQStore.getState().setOutputRoute(route); return route; } diff --git a/src/types/audio.ts b/src/types/audio.ts index d9e17a2..f3f62a6 100644 --- a/src/types/audio.ts +++ b/src/types/audio.ts @@ -85,7 +85,14 @@ export interface EQPreset { graphicGains?: number[]; } -export type AudioOutputRouteKind = 'speaker' | 'wired' | 'bluetooth' | 'usb' | 'hdmi' | 'unknown'; +export type AudioOutputRouteKind = + | 'speaker' + | 'wired' + | 'bluetooth' + | 'usb' + | 'hdmi' + | 'remote' + | 'unknown'; export interface AudioOutputRoute { key: string;