From 60a50d68fb685a66ef9e74dc81f4bd8ed1985776 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:42:48 -0400 Subject: [PATCH] better eq profile assigning --- .../astraaudioroute/AstraAudioRouteModule.kt | 36 +-- .../astraaudioroute/OutputDeviceSelector.kt | 40 +++ .../OutputDeviceSelectorTest.kt | 31 +++ src/app/(tabs)/eq.tsx | 89 +++++- src/audio/eqDevicePresets.test.mts | 148 ++++++++++ src/audio/eqDevicePresets.ts | 258 ++++++++++++++++++ src/audio/eqRouteProfiles.test.mts | 8 +- src/audio/eqRouteProfiles.ts | 29 +- .../eq/PresetDeviceAssignmentSheet.tsx | 226 +++++++++++++++ src/components/eq/PresetSheet.tsx | 120 +++++--- src/components/eq/SavePresetSheet.tsx | 48 +++- src/components/sheets/AppSheet.tsx | 48 +++- src/stores/eqStore.ts | 202 +++++++++----- 13 files changed, 1125 insertions(+), 158 deletions(-) create mode 100644 src/audio/eqDevicePresets.test.mts create mode 100644 src/audio/eqDevicePresets.ts create mode 100644 src/components/eq/PresetDeviceAssignmentSheet.tsx 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 5680603..3c95f75 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 @@ -131,7 +131,7 @@ class AstraAudioRouteModule : Module() { val device = selectOutputDevice() val kind = device?.let { kindForType(it.type) } ?: "unknown" val label = displayLabel(kind, device, selectedRouteName) - val key = routeKey(kind, label) + val key = buildOutputRouteKey(kind, label, deviceAddress(device)) return mapOf( "key" to key, @@ -214,6 +214,17 @@ class AstraAudioRouteModule : Module() { return routeName ?: deviceName ?: defaultLabel(kind) } + private fun deviceAddress(device: AudioDeviceInfo?): String? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + device?.address?.trim()?.ifEmpty { null } + } catch (_: Throwable) { + null + } + } else { + null + } + private fun defaultLabel(kind: String): String = when (kind) { "speaker" -> "Phone speaker" @@ -244,27 +255,4 @@ class AstraAudioRouteModule : Module() { 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('-') } 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 f175ee4..128fc38 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 @@ -1,5 +1,7 @@ package expo.modules.astraaudioroute +import java.security.MessageDigest + /** Prefer Android's media-attribute prediction, then retain the legacy fallback. */ internal fun selectPredictedOutputDevice( predicted: List, @@ -15,3 +17,41 @@ internal fun selectPredictedOutputDevice( ?: connected.firstOrNull { kindFor(it) == "speaker" } ?: connected.first() } + +private fun slug(value: String): String = + value + .trim() + .lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + +private fun isGenericExternalLabel(kind: String, label: String): Boolean { + val normalized = slug(label) + if (normalized.isEmpty()) return true + return when (kind) { + "bluetooth" -> normalized in setOf("bluetooth", "bluetooth-audio", "headphones", "headset") + "usb" -> normalized in setOf("usb", "usb-audio") + "hdmi" -> normalized in setOf("hdmi", "hdmi-audio") + else -> true + } +} + +private fun addressToken(address: String?): String? { + val normalized = address?.trim()?.lowercase()?.ifEmpty { null } ?: return null + return MessageDigest + .getInstance("SHA-256") + .digest(normalized.toByteArray(Charsets.UTF_8)) + .take(8) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } +} + +/** Builds a stable local identity without exposing a raw hardware address to JS/storage. */ +internal fun buildOutputRouteKey(kind: String, label: String, address: String?): String { + if (kind == "speaker") return "speaker" + if (kind == "wired") return "wired" + if (kind !in setOf("bluetooth", "usb", "hdmi")) return "unknown" + + addressToken(address)?.let { return "$kind:id:$it" } + if (!isGenericExternalLabel(kind, label)) return "$kind:name:${slug(label)}" + return kind +} 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 94f081e..e3ce114 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 @@ -1,6 +1,8 @@ package expo.modules.astraaudioroute import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test class OutputDeviceSelectorTest { @@ -34,4 +36,33 @@ class OutputDeviceSelectorTest { assertEquals(bluetooth, selected) } + + @Test + fun externalAddressProducesStablePrivacySafeKey() { + val first = buildOutputRouteKey("bluetooth", "Sony WH-1000XM5", "AA:BB:CC:DD:EE:FF") + val same = buildOutputRouteKey("bluetooth", "Renamed headphones", "aa:bb:cc:dd:ee:ff") + val other = buildOutputRouteKey("bluetooth", "Sony WH-1000XM5", "11:22:33:44:55:66") + + assertTrue(first.startsWith("bluetooth:id:")) + assertEquals(first, same) + assertNotEquals(first, other) + assertTrue(!first.contains("aa:bb")) + } + + @Test + fun namedExternalDevicesFallBackToLabelIdentity() { + assertEquals("bluetooth:name:sony-wh-1000xm5", buildOutputRouteKey("bluetooth", "Sony WH-1000XM5", null)) + assertEquals("usb:name:fiio-k7", buildOutputRouteKey("usb", "FiiO K7", null)) + assertEquals("hdmi:name:living-room-tv", buildOutputRouteKey("hdmi", "Living Room TV", null)) + } + + @Test + fun genericAndBuiltInOutputsKeepClassKeys() { + assertEquals("speaker", buildOutputRouteKey("speaker", "Pixel speaker", "internal")) + assertEquals("wired", buildOutputRouteKey("wired", "3.5mm", "jack")) + assertEquals("bluetooth", buildOutputRouteKey("bluetooth", "Bluetooth audio", null)) + assertEquals("usb", buildOutputRouteKey("usb", "USB audio", null)) + assertEquals("hdmi", buildOutputRouteKey("hdmi", "HDMI audio", null)) + assertEquals("unknown", buildOutputRouteKey("unknown", "Unknown output", null)) + } } diff --git a/src/app/(tabs)/eq.tsx b/src/app/(tabs)/eq.tsx index d0783ca..add3331 100644 --- a/src/app/(tabs)/eq.tsx +++ b/src/app/(tabs)/eq.tsx @@ -35,6 +35,7 @@ import { SavePresetSheet } from '@/components/eq/SavePresetSheet'; import { EQPresetNameSheet } from '@/components/eq/EQPresetNameSheet'; import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet'; import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet'; +import { PresetDeviceAssignmentSheet } from '@/components/eq/PresetDeviceAssignmentSheet'; import { radius, spacing, @@ -73,7 +74,16 @@ import { import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format'; import type { EQBand, EQBandType, EQPreset } from '@/types/audio'; -type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type' | 'shareName' | 'qr' | 'preview'; +type SheetKind = + | 'none' + | 'preset' + | 'assignDevices' + | 'save' + | 'overflow' + | 'type' + | 'shareName' + | 'qr' + | 'preview'; type CurrentPresetAction = 'export' | 'share' | 'qr'; type EQState = ReturnType; @@ -99,6 +109,7 @@ export default function EQScreen() { const [pendingCurrentAction, setPendingCurrentAction] = useState(null); const [pendingImportPreset, setPendingImportPreset] = useState(null); const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null); + const [actionPresetId, setActionPresetId] = useState(null); const closeSheet = useCallback(() => setSheet('none'), []); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); @@ -128,6 +139,20 @@ export default function EQScreen() { const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1; const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom'; const outputRouteLabel = eq.activeOutputRoute?.label ?? 'This phone'; + const assignedPresetId = eq.activeOutputRoute + ? eq.devicePresetAssignments[eq.activeOutputRoute.key] + : null; + const assignedPresetName = assignedPresetId + ? eq.presets.find((preset) => preset.id === assignedPresetId)?.name ?? null + : null; + const outputAssignmentLabel = eq.activeOutputRoute + ? assignedPresetName + ? `Auto: ${assignedPresetName}` + : 'No auto preset' + : 'Detecting output'; + const actionPreset = actionPresetId + ? eq.presets.find((preset) => preset.id === actionPresetId) ?? null + : null; const defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`; const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null; @@ -149,6 +174,30 @@ export default function EQScreen() { Alert.alert('Could not import preset', message); }, []); + const deletePreset = useCallback((preset: EQPreset) => { + const state = useEQStore.getState(); + if (!preset.isCustom || !state.presets.some((candidate) => candidate.id === preset.id)) return; + const assignmentCount = Object.values(state.devicePresetAssignments) + .filter((assignedPresetId) => assignedPresetId === preset.id).length; + const finishDelete = () => { + useEQStore.getState().deleteCustomPreset(preset.id); + setSheet('none'); + playHaptic('confirm'); + }; + if (assignmentCount === 0) { + finishDelete(); + return; + } + Alert.alert( + `Delete ${preset.name}?`, + `This will also clear ${assignmentCount} device assignment${assignmentCount === 1 ? '' : 's'}. The current sound will not change.`, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: finishDelete }, + ] + ); + }, []); + const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => { const preset = buildCurrentEQPreset(useEQStore.getState(), name); try { @@ -345,7 +394,7 @@ export default function EQScreen() { numberOfLines={1} style={styles.routeLabel} > - {`Tuning ${outputRouteLabel}`} + {`${outputRouteLabel} ยท ${outputAssignmentLabel}`} @@ -400,8 +449,14 @@ export default function EQScreen() { { + setActionPresetId(preset.id); + setSheet('assignDevices'); + }} + onDelete={deletePreset} onSaveNew={() => setSheet('save')} onClose={closeSheet} /> @@ -410,7 +465,33 @@ export default function EQScreen() { {sheet === 'save' ? ( eq.saveCustomPreset(name)} + currentDeviceLabel={ + eq.activeOutputRoute && eq.activeOutputRoute.kind !== 'unknown' + ? eq.activeOutputRoute.label + : null + } + onSave={(name, assignToCurrentDevice) => { + const presetId = eq.saveCustomPreset(name); + if ( + assignToCurrentDevice && + eq.activeOutputRoute && + eq.activeOutputRoute.kind !== 'unknown' + ) { + eq.assignPresetToDevices(presetId, [eq.activeOutputRoute.key]); + } + }} + onClose={closeSheet} + /> + ) : null} + + {sheet === 'assignDevices' && actionPreset ? ( + eq.assignPresetToDevices(actionPreset.id, deviceKeys)} onClose={closeSheet} /> ) : null} diff --git a/src/audio/eqDevicePresets.test.mts b/src/audio/eqDevicePresets.test.mts new file mode 100644 index 0000000..11ec982 --- /dev/null +++ b/src/audio/eqDevicePresets.test.mts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AudioOutputRoute } from '../types/audio.ts'; +import type { EQRouteProfile } from './eqRouteProfiles.ts'; +import { + migrateLegacyEQRouteProfiles, + observeEQOutputDevice, + parseEQDevicePresetStateJson, + presetForOutputRouteTransition, + pruneEQDevicePresetAssignments, + removePresetDeviceAssignments, + replacePresetDeviceAssignments, + stringifyEQDevicePresetState, + type EQDevicePresetState, +} from './eqDevicePresets.ts'; + +function state(overrides: Partial = {}): EQDevicePresetState { + return { + version: 1, + devices: overrides.devices ?? { + speaker: { key: 'speaker', label: 'Phone speaker', kind: 'speaker', lastSeenAt: 1 }, + 'bluetooth:name:sony': { + key: 'bluetooth:name:sony', + label: 'Sony', + kind: 'bluetooth', + lastSeenAt: 2, + }, + }, + assignments: overrides.assignments ?? {}, + }; +} + +function route(overrides: Partial = {}): AudioOutputRoute { + return { + key: overrides.key ?? 'speaker', + label: overrides.label ?? 'Phone speaker', + kind: overrides.kind ?? 'speaker', + nativeType: overrides.nativeType ?? null, + nativeId: overrides.nativeId ?? null, + selectedRouteName: overrides.selectedRouteName ?? null, + updatedAt: overrides.updatedAt ?? 1, + }; +} + +test('recovers from corrupt device assignment storage', () => { + assert.deepEqual(parseEQDevicePresetStateJson('{not-json'), { + version: 1, + devices: {}, + assignments: {}, + }); + assert.deepEqual(parseEQDevicePresetStateJson(JSON.stringify({ version: 99 })), { + version: 1, + devices: {}, + assignments: {}, + }); +}); + +test('round-trips known devices and valid assignments', () => { + const source = state({ assignments: { speaker: 'flat' } }); + assert.deepEqual(parseEQDevicePresetStateJson(stringifyEQDevicePresetState(source)), source); +}); + +test('replaces one preset checklist and moves devices from other presets', () => { + const source = state({ + assignments: { + speaker: 'vocal', + 'bluetooth:name:sony': 'bass', + }, + }); + const next = replacePresetDeviceAssignments(source, 'vocal', ['bluetooth:name:sony']); + + assert.deepEqual(next.assignments, { 'bluetooth:name:sony': 'vocal' }); + assert.deepEqual(removePresetDeviceAssignments(next, 'vocal').assignments, {}); +}); + +test('prunes missing presets without dropping known devices', () => { + const source = state({ assignments: { speaker: 'deleted', 'bluetooth:name:sony': 'vocal' } }); + const next = pruneEQDevicePresetAssignments(source, (presetId) => presetId === 'vocal'); + + assert.deepEqual(next.devices, source.devices); + assert.deepEqual(next.assignments, { 'bluetooth:name:sony': 'vocal' }); +}); + +test('legacy route snapshots migrate only their device metadata', () => { + const legacy: EQRouteProfile = { + version: 1, + routeKey: 'bluetooth:sony', + routeLabel: 'Sony', + routeKind: 'bluetooth', + enabled: true, + preamp: -6, + mode: 'parametric', + bands: [{ id: 'band', type: 'peaking', frequency: 1000, gain: 8, Q: 1, enabled: true }], + graphicGains: [1, 2, 3, 4, 5], + activePresetId: 'bass', + updatedAt: 42, + }; + const migrated = migrateLegacyEQRouteProfiles({ [legacy.routeKey]: legacy }); + + assert.deepEqual(migrated.assignments, {}); + assert.deepEqual(migrated.devices[legacy.routeKey], { + key: legacy.routeKey, + label: 'Sony', + kind: 'bluetooth', + lastSeenAt: 42, + }); + assert.equal('bands' in migrated.devices[legacy.routeKey], false); +}); + +test('observing a stronger native identity rekeys a migrated device', () => { + const source = state({ + devices: { + 'bluetooth:sony': { + key: 'bluetooth:sony', + label: 'Sony', + kind: 'bluetooth', + lastSeenAt: 10, + }, + }, + }); + const observed = observeEQOutputDevice( + source, + route({ + key: 'bluetooth:id:abc123', + label: 'Sony', + kind: 'bluetooth', + updatedAt: 20, + }) + ); + + assert.equal(observed.devices['bluetooth:sony'], undefined); + assert.deepEqual(observed.devices['bluetooth:id:abc123'], { + key: 'bluetooth:id:abc123', + label: 'Sony', + kind: 'bluetooth', + lastSeenAt: 20, + }); +}); + +test('only a real transition to an assigned device requests preset application', () => { + const assignments = { speaker: 'vocal' }; + const exists = (presetId: string) => presetId === 'vocal'; + + assert.equal(presetForOutputRouteTransition(null, 'speaker', assignments, exists), 'vocal'); + assert.equal(presetForOutputRouteTransition('speaker', 'speaker', assignments, exists), null); + assert.equal(presetForOutputRouteTransition('wired', 'bluetooth', assignments, exists), null); + assert.equal(presetForOutputRouteTransition('wired', 'speaker', assignments, () => false), null); +}); diff --git a/src/audio/eqDevicePresets.ts b/src/audio/eqDevicePresets.ts new file mode 100644 index 0000000..a916b83 --- /dev/null +++ b/src/audio/eqDevicePresets.ts @@ -0,0 +1,258 @@ +import type { AudioOutputRoute, AudioOutputRouteKind } from '../types/audio.ts'; +import type { EQRouteProfile } from './eqRouteProfiles.ts'; + +export const EQ_DEVICE_PRESET_STATE_VERSION = 1; + +export interface KnownEQOutputDevice { + key: string; + label: string; + kind: AudioOutputRouteKind; + lastSeenAt: number; +} + +export interface EQDevicePresetState { + version: typeof EQ_DEVICE_PRESET_STATE_VERSION; + devices: Record; + assignments: Record; +} + +const KNOWN_DEVICE_SEEN_WRITE_INTERVAL_MS = 60_000; + +function isRouteKind(value: unknown): value is AudioOutputRouteKind { + return ( + value === 'speaker' || + value === 'wired' || + value === 'bluetooth' || + value === 'usb' || + value === 'hdmi' || + value === 'unknown' + ); +} + +function trim(value: unknown): string | null { + if (typeof value !== 'string') return null; + const out = value.trim(); + return out.length > 0 ? out : null; +} + +function timestamp(value: unknown, fallback = Date.now()): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : fallback; +} + +function slug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function emptyState(): EQDevicePresetState { + return { + version: EQ_DEVICE_PRESET_STATE_VERSION, + devices: {}, + assignments: {}, + }; +} + +function parseDevice(value: unknown, key: string): KnownEQOutputDevice | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Partial; + const label = trim(raw.label); + if (!label || !isRouteKind(raw.kind) || raw.kind === 'unknown') return null; + return { + key, + label, + kind: raw.kind, + lastSeenAt: timestamp(raw.lastSeenAt), + }; +} + +/** Invalid fields are dropped independently so one bad device cannot erase the list. */ +export function parseEQDevicePresetState(value: unknown): EQDevicePresetState { + if (!value || typeof value !== 'object' || Array.isArray(value)) return emptyState(); + const raw = value as Partial; + if (raw.version !== EQ_DEVICE_PRESET_STATE_VERSION) return emptyState(); + + const devices: Record = {}; + if (raw.devices && typeof raw.devices === 'object' && !Array.isArray(raw.devices)) { + for (const [rawKey, rawDevice] of Object.entries(raw.devices)) { + const key = trim(rawKey); + if (!key) continue; + const device = parseDevice(rawDevice, key); + if (device) devices[key] = device; + } + } + + const assignments: Record = {}; + if (raw.assignments && typeof raw.assignments === 'object' && !Array.isArray(raw.assignments)) { + for (const [rawDeviceKey, rawPresetId] of Object.entries(raw.assignments)) { + const deviceKey = trim(rawDeviceKey); + const presetId = trim(rawPresetId); + if (deviceKey && presetId && devices[deviceKey]) assignments[deviceKey] = presetId; + } + } + + return { + version: EQ_DEVICE_PRESET_STATE_VERSION, + devices, + assignments, + }; +} + +export function parseEQDevicePresetStateJson(json: string | null): EQDevicePresetState { + if (!json) return emptyState(); + try { + return parseEQDevicePresetState(JSON.parse(json)); + } catch { + return emptyState(); + } +} + +export function stringifyEQDevicePresetState(state: EQDevicePresetState): string { + return JSON.stringify({ + version: EQ_DEVICE_PRESET_STATE_VERSION, + devices: state.devices, + assignments: state.assignments, + } satisfies EQDevicePresetState); +} + +/** + * The old route snapshot format is intentionally reduced to device history. + * Its bands, enable state, and preset references must never become assignments. + */ +export function migrateLegacyEQRouteProfiles( + profiles: Record +): EQDevicePresetState { + const devices: Record = {}; + for (const [key, profile] of Object.entries(profiles)) { + if (profile.routeKind === 'unknown') continue; + devices[key] = { + key, + label: profile.routeLabel, + kind: profile.routeKind, + lastSeenAt: timestamp(profile.updatedAt), + }; + } + return { + version: EQ_DEVICE_PRESET_STATE_VERSION, + devices, + assignments: {}, + }; +} + +function legacyRouteKey(kind: AudioOutputRouteKind, label: string): string { + if (kind === 'bluetooth') { + const labelSlug = slug(label); + if ( + labelSlug && + labelSlug !== 'bluetooth' && + labelSlug !== 'bluetooth-audio' && + labelSlug !== 'headphones' && + labelSlug !== 'headset' + ) { + return `bluetooth:${labelSlug}`; + } + } + return kind; +} + +/** Records a resolved output without exposing route history to the live EQ state. */ +export function observeEQOutputDevice( + state: EQDevicePresetState, + route: AudioOutputRoute +): EQDevicePresetState { + if (route.kind === 'unknown' || route.key === 'unknown') return state; + + let devices = state.devices; + let assignments = state.assignments; + + // Re-key a device retained from the old label/class-based snapshot format the + // first time the native module supplies its stronger address/name identity. + const oldKey = legacyRouteKey(route.kind, route.label); + if (oldKey !== route.key && devices[oldKey] && !devices[route.key]) { + const { [oldKey]: legacyDevice, ...remainingDevices } = devices; + devices = remainingDevices; + const { [oldKey]: legacyAssignment, ...remainingAssignments } = assignments; + assignments = legacyAssignment + ? { ...remainingAssignments, [route.key]: legacyAssignment } + : remainingAssignments; + void legacyDevice; + } + + const previous = devices[route.key]; + const labelChanged = previous?.label !== route.label || previous?.kind !== route.kind; + const lastSeenChanged = + !previous || route.updatedAt - previous.lastSeenAt >= KNOWN_DEVICE_SEEN_WRITE_INTERVAL_MS; + if (previous && !labelChanged && !lastSeenChanged && devices === state.devices) return state; + + return { + version: EQ_DEVICE_PRESET_STATE_VERSION, + devices: { + ...devices, + [route.key]: { + key: route.key, + label: route.label, + kind: route.kind, + lastSeenAt: Math.max(previous?.lastSeenAt ?? 0, route.updatedAt), + }, + }, + assignments, + }; +} + +export function pruneEQDevicePresetAssignments( + state: EQDevicePresetState, + presetExists: (presetId: string) => boolean +): EQDevicePresetState { + const assignments: Record = {}; + for (const [deviceKey, presetId] of Object.entries(state.assignments)) { + if (state.devices[deviceKey] && presetExists(presetId)) assignments[deviceKey] = presetId; + } + if ( + Object.keys(assignments).length === Object.keys(state.assignments).length && + Object.entries(assignments).every(([key, value]) => state.assignments[key] === value) + ) { + return state; + } + return { ...state, assignments }; +} + +/** Replaces one preset's device checklist and moves selected devices atomically. */ +export function replacePresetDeviceAssignments( + state: EQDevicePresetState, + presetId: string, + selectedDeviceKeys: readonly string[] +): EQDevicePresetState { + const assignments: Record = {}; + for (const [deviceKey, assignedPresetId] of Object.entries(state.assignments)) { + if (assignedPresetId !== presetId) assignments[deviceKey] = assignedPresetId; + } + for (const deviceKey of new Set(selectedDeviceKeys)) { + if (state.devices[deviceKey]) assignments[deviceKey] = presetId; + } + return { ...state, assignments }; +} + +export function removePresetDeviceAssignments( + state: EQDevicePresetState, + presetId: string +): EQDevicePresetState { + const assignments = Object.fromEntries( + Object.entries(state.assignments).filter(([, assignedPresetId]) => assignedPresetId !== presetId) + ); + return { ...state, assignments }; +} + +/** Same-route events only refresh metadata; they must not erase live manual edits. */ +export function presetForOutputRouteTransition( + previousDeviceKey: string | null, + nextDeviceKey: string, + assignments: Readonly>, + presetExists: (presetId: string) => boolean +): string | null { + if (previousDeviceKey === nextDeviceKey) return null; + const presetId = assignments[nextDeviceKey]; + return presetId && presetExists(presetId) ? presetId : null; +} diff --git a/src/audio/eqRouteProfiles.test.mts b/src/audio/eqRouteProfiles.test.mts index 16aee03..94d240e 100644 --- a/src/audio/eqRouteProfiles.test.mts +++ b/src/audio/eqRouteProfiles.test.mts @@ -42,7 +42,7 @@ function route(overrides: Partial = {}): AudioOutputRoute { test('normalizes route keys for named and unnamed Bluetooth outputs', () => { assert.equal( normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Sony WH-1000XM5' })?.key, - 'bluetooth:sony-wh-1000xm5' + 'bluetooth:name:sony-wh-1000xm5' ); assert.equal( normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Bluetooth audio' })?.key, @@ -50,9 +50,11 @@ test('normalizes route keys for named and unnamed Bluetooth outputs', () => { ); }); -test('normalizes class routes for wired, usb, speaker, and unknown outputs', () => { +test('normalizes named external routes and generic class routes', () => { assert.equal(normalizeAudioOutputRoute({ kind: 'wired', label: '3.5mm' })?.key, 'wired'); - assert.equal(normalizeAudioOutputRoute({ kind: 'usb', label: 'USB DAC' })?.key, 'usb'); + 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: 'speaker', label: 'Pixel speaker' })?.key, 'speaker'); assert.equal(normalizeAudioOutputRoute({ kind: 'nonsense', label: '' })?.key, 'unknown'); }); diff --git a/src/audio/eqRouteProfiles.ts b/src/audio/eqRouteProfiles.ts index 841caf2..ac8c63b 100644 --- a/src/audio/eqRouteProfiles.ts +++ b/src/audio/eqRouteProfiles.ts @@ -89,20 +89,29 @@ function defaultRouteLabel(kind: AudioOutputRouteKind): string { } } -function isGenericBluetoothLabel(label: string): boolean { +function isGenericExternalLabel(kind: AudioOutputRouteKind, label: string): boolean { const normalized = slug(label); - return ( - normalized.length === 0 || - normalized === 'bluetooth' || - normalized === 'bluetooth-audio' || - normalized === 'headphones' || - normalized === 'headset' - ); + if (normalized.length === 0) return true; + if (kind === 'bluetooth') { + return ( + normalized === 'bluetooth' || + normalized === 'bluetooth-audio' || + normalized === 'headphones' || + normalized === 'headset' + ); + } + if (kind === 'usb') return normalized === 'usb' || normalized === 'usb-audio'; + if (kind === 'hdmi') return normalized === 'hdmi' || normalized === 'hdmi-audio'; + return true; } export function buildAudioOutputRouteKey(kind: AudioOutputRouteKind, label: string | null): string { - if (kind === 'bluetooth' && label && !isGenericBluetoothLabel(label)) { - return `bluetooth:${slug(label)}`; + if ( + (kind === 'bluetooth' || kind === 'usb' || kind === 'hdmi') && + label && + !isGenericExternalLabel(kind, label) + ) { + return `${kind}:name:${slug(label)}`; } switch (kind) { case 'speaker': diff --git a/src/components/eq/PresetDeviceAssignmentSheet.tsx b/src/components/eq/PresetDeviceAssignmentSheet.tsx new file mode 100644 index 0000000..b249d72 --- /dev/null +++ b/src/components/eq/PresetDeviceAssignmentSheet.tsx @@ -0,0 +1,226 @@ +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { EqSheet } from '@/components/eq/EqSheet'; +import type { KnownEQOutputDevice } from '@/audio/eqDevicePresets'; +import type { EQPreset } from '@/types/audio'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; +import { playHaptic } from '@/lib/haptics'; + +interface PresetDeviceAssignmentSheetProps { + preset: EQPreset; + devices: KnownEQOutputDevice[]; + assignments: Readonly>; + presets: EQPreset[]; + currentDeviceKey: string | null; + onSave: (deviceKeys: string[]) => void; + onClose: () => void; +} + +function kindLabel(device: KnownEQOutputDevice): string { + switch (device.kind) { + case 'speaker': + return 'Phone speaker'; + case 'wired': + return 'Wired output'; + case 'bluetooth': + return 'Bluetooth'; + case 'usb': + return 'USB audio'; + case 'hdmi': + return 'HDMI audio'; + default: + return 'Audio output'; + } +} + +/** Optional Poweramp-style automation: one saved preset may own many devices. */ +export function PresetDeviceAssignmentSheet({ + preset, + devices, + assignments, + presets, + currentDeviceKey, + onSave, + onClose, +}: PresetDeviceAssignmentSheetProps) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const [selected, setSelected] = useState>( + () => new Set(devices.filter((device) => assignments[device.key] === preset.id).map((device) => device.key)) + ); + const presetNames = useMemo( + () => new Map(presets.map((candidate) => [candidate.id, candidate.name])), + [presets] + ); + const sortedDevices = useMemo( + () => [...devices].sort((a, b) => { + if (a.key === currentDeviceKey) return -1; + if (b.key === currentDeviceKey) return 1; + return b.lastSeenAt - a.lastSeenAt || a.label.localeCompare(b.label); + }), + [currentDeviceKey, devices] + ); + + const toggleDevice = (deviceKey: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(deviceKey)) next.delete(deviceKey); + else next.add(deviceKey); + return next; + }); + playHaptic('selection'); + }; + + return ( + + + Assign {preset.name} + + + This preset will load automatically when a selected output becomes active. + + + {sortedDevices.length === 0 ? ( + + No audio outputs have been observed yet. + + ) : ( + + {sortedDevices.map((device) => { + const checked = selected.has(device.key); + const assignedPresetId = assignments[device.key]; + const assignedPresetName = assignedPresetId ? presetNames.get(assignedPresetId) : null; + const subtitle = assignedPresetId === preset.id + ? 'Assigned to this preset' + : assignedPresetName + ? `Currently assigned to ${assignedPresetName}` + : kindLabel(device); + return ( + toggleDevice(device.key)} + accessibilityRole="checkbox" + accessibilityState={{ checked }} + accessibilityLabel={`${device.label}${device.key === currentDeviceKey ? ', current output' : ''}`} + > + + + + {device.label} + + {device.key === currentDeviceKey ? ( + + CURRENT + + ) : null} + + + {subtitle} + + + + + ); + })} + + )} + + + + Cancel + + { + onSave([...selected]); + onClose(); + }} + > + Save assignments + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + title: { + marginTop: spacing.xs, + }, + description: { + lineHeight: 17, + marginTop: spacing.xs, + marginBottom: spacing.md, + }, + empty: { + paddingVertical: spacing.xl, + }, + list: { + gap: spacing.xs, + }, + deviceRow: { + minHeight: 60, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: colors.glassBg, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + overflow: 'hidden', + }, + deviceMeta: { + flex: 1, + minWidth: 0, + gap: 2, + }, + deviceTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + deviceTitle: { + flexShrink: 1, + }, + currentBadge: { + fontSize: 10, + letterSpacing: 0.7, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, + button: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderRadius: radius.pill, + }, + cancel: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + save: { + backgroundColor: colors.accentGlow, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.accent, + }, +})); + +export default PresetDeviceAssignmentSheet; diff --git a/src/components/eq/PresetSheet.tsx b/src/components/eq/PresetSheet.tsx index ba1c9f9..a2c086d 100644 --- a/src/components/eq/PresetSheet.tsx +++ b/src/components/eq/PresetSheet.tsx @@ -1,9 +1,10 @@ -import { Pressable, StyleSheet } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; -import { spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; +import type { KnownEQOutputDevice } from '@/audio/eqDevicePresets'; import type { EQPreset } from '@/types/audio'; import { EqSheet, @@ -14,8 +15,11 @@ import { interface PresetSheetProps { presets: EQPreset[]; activePresetId: string | null; + knownDevices: KnownEQOutputDevice[]; + assignments: Readonly>; onApply: (id: string) => void; - onDelete: (id: string) => void; + onAssign: (preset: EQPreset) => void; + onDelete: (preset: EQPreset) => void; onSaveNew: () => void; onClose: () => void; } @@ -32,7 +36,10 @@ function modeIcon(preset: EQPreset): 'options-outline' | 'analytics-outline' { export function PresetSheet({ presets, activePresetId, + knownDevices, + assignments, onApply, + onAssign, onDelete, onSaveNew, onClose, @@ -41,25 +48,69 @@ export function PresetSheet({ const ripple = useRipple(); const builtIn = presets.filter((p) => !p.isCustom); const custom = presets.filter((p) => p.isCustom); + const devicesByKey = new Map(knownDevices.map((device) => [device.key, device])); + + const renderPreset = (preset: EQPreset) => { + const assignedDeviceKeys = Object.entries(assignments) + .filter(([, presetId]) => presetId === preset.id) + .map(([deviceKey]) => deviceKey); + const assignmentSubtitle = assignedDeviceKeys.length === 1 + ? `Assigned to ${devicesByKey.get(assignedDeviceKeys[0])?.label ?? '1 device'}` + : assignedDeviceKeys.length > 1 + ? `Assigned to ${assignedDeviceKeys.length} devices` + : undefined; + return ( + { + onApply(preset.id); + onClose(); + }} + trailing={ + + onAssign(preset)} + style={styles.assignButton} + accessibilityLabel={`Assign devices to ${preset.name}`} + > + + Assign + + + + {preset.isCustom ? ( + onDelete(preset)} + style={styles.deleteButton} + accessibilityLabel={`Delete preset ${preset.name}`} + > + + + ) : null} + + } + /> + ); + }; return ( - + Presets - {builtIn.map((p) => ( - { - onApply(p.id); - onClose(); - }} - /> - ))} + {builtIn.map(renderPreset)} {custom.length === 0 ? ( @@ -67,28 +118,7 @@ export function PresetSheet({ No saved presets yet. ) : ( - custom.map((p) => ( - { - onApply(p.id); - onClose(); - }} - trailing={ - onDelete(p.id)} - style={styles.delete} - accessibilityLabel={`Delete preset ${p.name}`} - > - - - } - /> - )) + custom.map(renderPreset) )} void; + currentDeviceLabel?: string | null; + onSave: (name: string, assignToCurrentDevice: boolean) => void; onClose: () => void; } /** Name + save a custom preset from the current bands/preamp. */ -export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) { +export function SavePresetSheet({ + defaultName, + currentDeviceLabel, + onSave, + onClose, +}: SavePresetSheetProps) { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); const [name, setName] = useState(defaultName); + const [assignToCurrentDevice, setAssignToCurrentDevice] = useState(false); const trimmed = name.trim(); return ( @@ -46,11 +54,27 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee returnKeyType="done" onSubmitEditing={() => { if (trimmed) { - onSave(trimmed); + onSave(trimmed, assignToCurrentDevice); onClose(); } }} /> + {currentDeviceLabel ? ( + + + Assign to current output + + {currentDeviceLabel} + + + + + ) : null} @@ -61,7 +85,7 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]} disabled={!trimmed} onPress={() => { - onSave(trimmed); + onSave(trimmed, assignToCurrentDevice); onClose(); }} > @@ -90,6 +114,22 @@ const useStyles = createThemedStyles((colors) => ({ borderColor: colors.glassBorder, backgroundColor: colors.glassBg, }, + assignmentRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginTop: spacing.md, + padding: spacing.md, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + assignmentText: { + flex: 1, + minWidth: 0, + gap: 2, + }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/src/components/sheets/AppSheet.tsx b/src/components/sheets/AppSheet.tsx index dfd0d44..fd7467b 100644 --- a/src/components/sheets/AppSheet.tsx +++ b/src/components/sheets/AppSheet.tsx @@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import BottomSheet, { BottomSheetBackdrop, + BottomSheetScrollView, BottomSheetView, type BottomSheetBackdropProps } from '@gorhom/bottom-sheet'; @@ -19,7 +20,15 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { playHaptic } from '@/lib/haptics'; -export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) { +export function AppSheet({ + onClose, + children, + scrollable = false, +}: { + onClose: () => void; + children: ReactNode; + scrollable?: boolean; +}) { const styles = useStyles(); const insets = useSafeAreaInsets(); const renderBackdrop = useCallback( @@ -45,9 +54,20 @@ export function AppSheet({ onClose, children }: { onClose: () => void; children: backgroundStyle={styles.sheetBg} handleIndicatorStyle={styles.handle} > - - {children} - + {scrollable ? ( + + {children} + + ) : ( + + {children} + + )} ); } @@ -80,6 +100,7 @@ export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: s export interface AppSheetItemProps { label: string; + subtitle?: string; icon?: keyof typeof Ionicons.glyphMap; selected?: boolean; destructive?: boolean; @@ -89,6 +110,7 @@ export interface AppSheetItemProps { export function AppSheetItem({ label, + subtitle, icon, selected, destructive, @@ -118,9 +140,16 @@ export function AppSheetItem({ {icon ? ( ) : null} - - {label} - + + + {label} + + {subtitle ? ( + + {subtitle} + + ) : null} + {selected ? : null} {trailing} @@ -168,7 +197,12 @@ const useStyles = createThemedStyles((colors) => ({ paddingVertical: spacing.md, }, itemLabel: { + flexShrink: 1, + }, + itemMeta: { flex: 1, + minWidth: 0, + gap: 2, }, })); diff --git a/src/stores/eqStore.ts b/src/stores/eqStore.ts index 38c16cb..29138b0 100644 --- a/src/stores/eqStore.ts +++ b/src/stores/eqStore.ts @@ -20,13 +20,21 @@ import { parseGraphicGains, } from '@/audio/graphicEq'; import { - createEQRouteProfile, normalizeAudioOutputRoute, parseEQRouteProfilesJson, - restoreEQRouteProfile, - stringifyEQRouteProfiles, - type EQRouteProfile, } from '@/audio/eqRouteProfiles'; +import { + migrateLegacyEQRouteProfiles, + observeEQOutputDevice, + parseEQDevicePresetStateJson, + presetForOutputRouteTransition, + pruneEQDevicePresetAssignments, + removePresetDeviceAssignments, + replacePresetDeviceAssignments, + stringifyEQDevicePresetState, + type EQDevicePresetState, + type KnownEQOutputDevice, +} from '@/audio/eqDevicePresets'; import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative'; /** @@ -43,6 +51,7 @@ const CUSTOM_PRESETS_KEY = 'eq_custom_presets'; const MODE_KEY = 'eq_mode'; const GRAPHIC_GAINS_KEY = 'eq_graphic_gains'; const ROUTE_PROFILES_KEY = 'eq_route_profiles_v1'; +const DEVICE_PRESETS_KEY = 'eq_device_preset_assignments_v1'; const PERSIST_DEBOUNCE_MS = 250; @@ -107,6 +116,8 @@ interface EQStore { activePresetId: string | null; // null = manually edited ("Custom") activeBandId: string | null; // UI selection shared by curve / strip / panel activeOutputRoute: AudioOutputRoute | null; + knownOutputDevices: Record; + devicePresetAssignments: Record; loaded: boolean; load: () => Promise; @@ -121,16 +132,16 @@ interface EQStore { selectBand: (id: string | null) => void; applyPreset: (presetId: string) => void; resetToFlat: () => void; - saveCustomPreset: (name: string) => void; + saveCustomPreset: (name: string) => string; deleteCustomPreset: (presetId: string) => void; importPreset: (preset: EQPreset) => void; + assignPresetToDevices: (presetId: string, deviceKeys: readonly string[]) => void; setOutputRoute: (route: AudioOutputRoute | null) => Promise; _syncToNative: () => void; } let persistTimer: ReturnType | null = null; -let routeProfilesByKey: Record = {}; export const useEQStore = create((set, get) => { function syncToNative(): void { @@ -141,24 +152,16 @@ export const useEQStore = create((set, get) => { setEqBandsNative(flattenBandsForNative(activeBands)); } - function captureRouteProfile(route: AudioOutputRoute | null = get().activeOutputRoute): void { - if (!route) return; - const { enabled, preamp, mode, bands, graphicGains, activePresetId } = get(); - routeProfilesByKey = { - ...routeProfilesByKey, - [route.key]: createEQRouteProfile(route, { - enabled, - preamp, - mode, - bands, - graphicGains, - activePresetId, - }), + function currentDevicePresetState(): EQDevicePresetState { + const { knownOutputDevices, devicePresetAssignments } = get(); + return { + version: 1, + devices: knownOutputDevices, + assignments: devicePresetAssignments, }; } function schedulePersist(): void { - captureRouteProfile(); if (persistTimer) clearTimeout(persistTimer); persistTimer = setTimeout(() => { persistTimer = null; @@ -167,9 +170,23 @@ export const useEQStore = create((set, get) => { } async function persistNow(): Promise { - captureRouteProfile(); - const { enabled, preamp, bands, mode, graphicGains, activePresetId, presets } = get(); + const { + enabled, + preamp, + bands, + mode, + graphicGains, + activePresetId, + presets, + knownOutputDevices, + devicePresetAssignments, + } = get(); const custom = presets.filter((p) => p.isCustom); + const devicePresetState: EQDevicePresetState = { + version: 1, + devices: knownOutputDevices, + assignments: devicePresetAssignments, + }; try { const db = await openLibraryDb(); await Promise.all([ @@ -179,7 +196,7 @@ export const useEQStore = create((set, get) => { setSetting(db, MODE_KEY, mode), setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)), setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''), - setSetting(db, ROUTE_PROFILES_KEY, stringifyEQRouteProfiles(routeProfilesByKey)), + setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(devicePresetState)), setSetting( db, CUSTOM_PRESETS_KEY, @@ -216,12 +233,24 @@ export const useEQStore = create((set, get) => { activePresetId: FLAT_PRESET_ID, activeBandId: null, activeOutputRoute: null, + knownOutputDevices: {}, + devicePresetAssignments: {}, loaded: false, load: async () => { if (get().loaded) return; const db = await openLibraryDb(); - const [enabledRaw, preampRaw, bandsRaw, modeRaw, gainsRaw, activeRaw, customRaw, routeProfilesRaw] = await Promise.all([ + const [ + enabledRaw, + preampRaw, + bandsRaw, + modeRaw, + gainsRaw, + activeRaw, + customRaw, + devicePresetsRaw, + routeProfilesRaw, + ] = await Promise.all([ getSetting(db, ENABLED_KEY), getSetting(db, PREAMP_KEY), getSetting(db, BANDS_KEY), @@ -229,13 +258,20 @@ export const useEQStore = create((set, get) => { getSetting(db, GRAPHIC_GAINS_KEY), getSetting(db, ACTIVE_PRESET_KEY), getSetting(db, CUSTOM_PRESETS_KEY), + getSetting(db, DEVICE_PRESETS_KEY), getSetting(db, ROUTE_PROFILES_KEY), ]); const bands = parseBands(bandsRaw) ?? createDefaultBands(); const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)]; const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null; - routeProfilesByKey = parseEQRouteProfilesJson(routeProfilesRaw, genEqId); + const parsedDeviceState = devicePresetsRaw === null + ? migrateLegacyEQRouteProfiles(parseEQRouteProfilesJson(routeProfilesRaw, genEqId)) + : parseEQDevicePresetStateJson(devicePresetsRaw); + const deviceState = pruneEQDevicePresetAssignments( + parsedDeviceState, + (presetId) => presets.some((preset) => preset.id === presetId) + ); set({ enabled: enabledRaw === 'true', @@ -245,13 +281,24 @@ export const useEQStore = create((set, get) => { mode: modeRaw === 'graphic' ? 'graphic' : 'parametric', graphicGains: parseGraphicGains(safeJsonParse(gainsRaw)) ?? createFlatGraphicGains(), presets, - // Stored active preset ids are regenerated on load (parseCustomPresets makes - // new ids), so only built-in ids survive a reload; fall back to "Custom". + // Missing/deleted preset references fall back to the freely editable state. activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null, activeBandId: bands[0]?.id ?? null, + knownOutputDevices: deviceState.devices, + devicePresetAssignments: deviceState.assignments, loaded: true, }); syncToNative(); + + // The presence of the new envelope is the migration marker. The legacy + // snapshots remain unread after this write and can no longer affect EQ. + if (devicePresetsRaw === null) { + try { + await setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(deviceState)); + } catch { + /* migration persistence failure is non-fatal and safe to retry */ + } + } }, setEnabled: (enabled) => { @@ -401,13 +448,16 @@ export const useEQStore = create((set, get) => { }; set({ presets: [...presets, preset], activePresetId: preset.id }); schedulePersist(); + return preset.id; }, deleteCustomPreset: (presetId) => { const { presets, activePresetId } = get(); + const deviceState = removePresetDeviceAssignments(currentDevicePresetState(), presetId); set({ presets: presets.filter((p) => p.id !== presetId), activePresetId: activePresetId === presetId ? null : activePresetId, + devicePresetAssignments: deviceState.assignments, }); schedulePersist(); }, @@ -426,59 +476,75 @@ export const useEQStore = create((set, get) => { get().applyPreset(stored.id); }, + assignPresetToDevices: (presetId, deviceKeys) => { + if (!get().presets.some((preset) => preset.id === presetId)) return; + const previousAssignments = get().devicePresetAssignments; + const nextState = replacePresetDeviceAssignments( + currentDevicePresetState(), + presetId, + deviceKeys + ); + const activeDeviceKey = get().activeOutputRoute?.key ?? null; + const previousActivePresetId = activeDeviceKey ? previousAssignments[activeDeviceKey] : null; + const nextActivePresetId = activeDeviceKey ? nextState.assignments[activeDeviceKey] : null; + + set({ devicePresetAssignments: nextState.assignments }); + schedulePersist(); + + // Only a changed assignment is an explicit request to replace the live + // working state. Saving an unchanged checklist must preserve manual tweaks. + if ( + activeDeviceKey && + nextActivePresetId === presetId && + previousActivePresetId !== nextActivePresetId + ) { + get().applyPreset(presetId); + } + }, + setOutputRoute: async (route) => { if (!get().loaded) await get().load(); const nextRoute = normalizeAudioOutputRoute(route); const previousRoute = get().activeOutputRoute; - if (previousRoute?.key === nextRoute?.key) { - if ( - previousRoute && - nextRoute && - (previousRoute.label !== nextRoute.label || - previousRoute.kind !== nextRoute.kind || - previousRoute.nativeId !== nextRoute.nativeId || - previousRoute.nativeType !== nextRoute.nativeType || - previousRoute.selectedRouteName !== nextRoute.selectedRouteName) - ) { - set({ activeOutputRoute: nextRoute }); - schedulePersist(); - } - return; - } - - captureRouteProfile(previousRoute); - if (!nextRoute) { - set({ activeOutputRoute: null }); - schedulePersist(); + if (previousRoute) set({ activeOutputRoute: null }); return; } - const profile = routeProfilesByKey[nextRoute.key]; - if (!profile) { - // First-seen routes inherit whatever EQ is currently audible, then diverge - // as soon as the user edits while this route is active. - set({ activeOutputRoute: nextRoute }); - schedulePersist(); - return; + const previousDeviceState = currentDevicePresetState(); + const observedDeviceState = observeEQOutputDevice(previousDeviceState, nextRoute); + const deviceStateChanged = observedDeviceState !== previousDeviceState; + const activeRouteChanged = + previousRoute?.key !== nextRoute.key || + previousRoute.label !== nextRoute.label || + previousRoute.kind !== nextRoute.kind || + previousRoute.nativeId !== nextRoute.nativeId || + previousRoute.nativeType !== nextRoute.nativeType || + previousRoute.selectedRouteName !== nextRoute.selectedRouteName; + if (activeRouteChanged || deviceStateChanged) { + set({ + ...(activeRouteChanged ? { activeOutputRoute: nextRoute } : {}), + ...(deviceStateChanged + ? { + knownOutputDevices: observedDeviceState.devices, + devicePresetAssignments: observedDeviceState.assignments, + } + : {}), + }); } + if (deviceStateChanged) schedulePersist(); - const restored = restoreEQRouteProfile(profile, (presetId) => - get().presets.some((preset) => preset.id === presetId) + const assignedPresetId = presetForOutputRouteTransition( + previousRoute?.key ?? null, + nextRoute.key, + observedDeviceState.assignments, + (presetId) => get().presets.some((preset) => preset.id === presetId) ); - set({ - activeOutputRoute: nextRoute, - enabled: restored.enabled, - preamp: restored.preamp, - mode: restored.mode, - bands: restored.bands, - graphicGains: restored.graphicGains, - activePresetId: restored.activePresetId, - activeBandId: restored.bands[0]?.id ?? null, - }); - syncToNative(); - schedulePersist(); + if (assignedPresetId) { + // applyPreset intentionally does not touch `enabled`, so EQ power stays global. + get().applyPreset(assignedPresetId); + } }, _syncToNative: syncToNative,