better eq profile assigning

This commit is contained in:
Boof2015
2026-07-14 13:42:48 -04:00
parent 7b35e01fe0
commit 60a50d68fb
13 changed files with 1125 additions and 158 deletions
@@ -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('-')
}
@@ -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 <T> selectPredictedOutputDevice(
predicted: List<T>,
@@ -15,3 +17,41 @@ internal fun <T> 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
}
@@ -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))
}
}
+85 -4
View File
@@ -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<typeof useEQStore.getState>;
@@ -99,6 +109,7 @@ export default function EQScreen() {
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
const [actionPresetId, setActionPresetId] = useState<string | null>(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}`}
</Text>
</View>
<View style={styles.headerActions}>
@@ -400,8 +449,14 @@ export default function EQScreen() {
<PresetSheet
presets={eq.presets}
activePresetId={eq.activePresetId}
knownDevices={Object.values(eq.knownOutputDevices)}
assignments={eq.devicePresetAssignments}
onApply={eq.applyPreset}
onDelete={eq.deleteCustomPreset}
onAssign={(preset) => {
setActionPresetId(preset.id);
setSheet('assignDevices');
}}
onDelete={deletePreset}
onSaveNew={() => setSheet('save')}
onClose={closeSheet}
/>
@@ -410,7 +465,33 @@ export default function EQScreen() {
{sheet === 'save' ? (
<SavePresetSheet
defaultName={defaultPresetName}
onSave={(name) => 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 ? (
<PresetDeviceAssignmentSheet
preset={actionPreset}
devices={Object.values(eq.knownOutputDevices)}
assignments={eq.devicePresetAssignments}
presets={eq.presets}
currentDeviceKey={eq.activeOutputRoute?.key ?? null}
onSave={(deviceKeys) => eq.assignPresetToDevices(actionPreset.id, deviceKeys)}
onClose={closeSheet}
/>
) : null}
+148
View File
@@ -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> = {}): 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> = {}): 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);
});
+258
View File
@@ -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<string, KnownEQOutputDevice>;
assignments: Record<string, string>;
}
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<KnownEQOutputDevice>;
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<EQDevicePresetState>;
if (raw.version !== EQ_DEVICE_PRESET_STATE_VERSION) return emptyState();
const devices: Record<string, KnownEQOutputDevice> = {};
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<string, string> = {};
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<string, EQRouteProfile>
): EQDevicePresetState {
const devices: Record<string, KnownEQOutputDevice> = {};
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<string, string> = {};
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<string, string> = {};
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<Record<string, string>>,
presetExists: (presetId: string) => boolean
): string | null {
if (previousDeviceKey === nextDeviceKey) return null;
const presetId = assignments[nextDeviceKey];
return presetId && presetExists(presetId) ? presetId : null;
}
+5 -3
View File
@@ -42,7 +42,7 @@ function route(overrides: Partial<AudioOutputRoute> = {}): 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');
});
+19 -10
View File
@@ -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':
@@ -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<Record<string, string>>;
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<Set<string>>(
() => 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 (
<EqSheet onClose={onClose} scrollable>
<Text variant="heading" style={styles.title}>
Assign {preset.name}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.description}>
This preset will load automatically when a selected output becomes active.
</Text>
{sortedDevices.length === 0 ? (
<Text variant="body" color={colors.textTertiary} style={styles.empty}>
No audio outputs have been observed yet.
</Text>
) : (
<View style={styles.list}>
{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 (
<Pressable
key={device.key}
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.deviceRow}
onPress={() => toggleDevice(device.key)}
accessibilityRole="checkbox"
accessibilityState={{ checked }}
accessibilityLabel={`${device.label}${device.key === currentDeviceKey ? ', current output' : ''}`}
>
<View style={styles.deviceMeta}>
<View style={styles.deviceTitleRow}>
<Text variant="body" numberOfLines={1} style={styles.deviceTitle}>
{device.label}
</Text>
{device.key === currentDeviceKey ? (
<Text variant="caption" color={colors.accentTextStrong} style={styles.currentBadge}>
CURRENT
</Text>
) : null}
</View>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{subtitle}
</Text>
</View>
<Ionicons
name={checked ? 'checkbox' : 'square-outline'}
size={22}
color={checked ? colors.accent : colors.textTertiary}
/>
</Pressable>
);
})}
</View>
)}
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={[styles.button, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>Cancel</Text>
</Pressable>
<Pressable
android_ripple={ripple.bounded}
style={[styles.button, styles.save]}
onPress={() => {
onSave([...selected]);
onClose();
}}
>
<Text variant="label" color={colors.accentTextStrong}>Save assignments</Text>
</Pressable>
</View>
</EqSheet>
);
}
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;
+82 -38
View File
@@ -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<Record<string, string>>;
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 (
<EqSheetItem
key={preset.id}
label={preset.name}
subtitle={assignmentSubtitle}
icon={preset.isCustom ? modeIcon(preset) : undefined}
selected={preset.id === activePresetId}
onPress={() => {
onApply(preset.id);
onClose();
}}
trailing={
<View style={styles.trailingActions}>
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={6}
onPress={() => onAssign(preset)}
style={styles.assignButton}
accessibilityLabel={`Assign devices to ${preset.name}`}
>
<Text variant="label" color={colors.textSecondary}>
Assign
</Text>
<Ionicons name="chevron-forward" size={14} color={colors.textTertiary} />
</Pressable>
{preset.isCustom ? (
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={8}
onPress={() => onDelete(preset)}
style={styles.deleteButton}
accessibilityLabel={`Delete preset ${preset.name}`}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</View>
}
/>
);
};
return (
<EqSheet onClose={onClose}>
<EqSheet onClose={onClose} scrollable>
<Text variant="heading" style={styles.title}>
Presets
</Text>
<EqSheetSection label="BUILT-IN" />
{builtIn.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
/>
))}
{builtIn.map(renderPreset)}
<EqSheetSection label="CUSTOM" />
{custom.length === 0 ? (
@@ -67,28 +118,7 @@ export function PresetSheet({
No saved presets yet.
</Text>
) : (
custom.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
icon={modeIcon(p)}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
trailing={
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={10}
onPress={() => onDelete(p.id)}
style={styles.delete}
accessibilityLabel={`Delete preset ${p.name}`}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
}
/>
))
custom.map(renderPreset)
)}
<EqSheetItem
@@ -110,7 +140,21 @@ const styles = StyleSheet.create({
empty: {
paddingVertical: spacing.sm,
},
delete: {
trailingActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
assignButton: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: spacing.sm,
borderRadius: radius.pill,
overflow: 'hidden',
},
deleteButton: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
+44 -4
View File
@@ -6,6 +6,7 @@ import {
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { HapticSwitch } from '@/components/HapticSwitch';
import {
fonts,
radius,
@@ -17,16 +18,23 @@ import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
defaultName: string;
onSave: (name: string) => 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 ? (
<View style={styles.assignmentRow}>
<View style={styles.assignmentText}>
<Text variant="body">Assign to current output</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{currentDeviceLabel}
</Text>
</View>
<HapticSwitch
value={assignToCurrentDevice}
onValueChange={setAssignToCurrentDevice}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
) : null}
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
@@ -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',
+41 -7
View File
@@ -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}
>
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
{children}
</BottomSheetView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<BottomSheetView
style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
>
{children}
</BottomSheetView>
)}
</BottomSheet>
);
}
@@ -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 ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
) : null}
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
<View style={styles.itemMeta}>
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
{subtitle ? (
<Text variant="caption" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</Pressable>
{trailing}
@@ -168,7 +197,12 @@ const useStyles = createThemedStyles((colors) => ({
paddingVertical: spacing.md,
},
itemLabel: {
flexShrink: 1,
},
itemMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
}));
+134 -68
View File
@@ -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<string, KnownEQOutputDevice>;
devicePresetAssignments: Record<string, string>;
loaded: boolean;
load: () => Promise<void>;
@@ -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<void>;
_syncToNative: () => void;
}
let persistTimer: ReturnType<typeof setTimeout> | null = null;
let routeProfilesByKey: Record<string, EQRouteProfile> = {};
export const useEQStore = create<EQStore>((set, get) => {
function syncToNative(): void {
@@ -141,24 +152,16 @@ export const useEQStore = create<EQStore>((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<EQStore>((set, get) => {
}
async function persistNow(): Promise<void> {
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<EQStore>((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<EQStore>((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<EQStore>((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<EQStore>((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<EQStore>((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<EQStore>((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,