mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
better eq profile assigning
This commit is contained in:
+12
-24
@@ -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('-')
|
||||
}
|
||||
|
||||
+40
@@ -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
|
||||
}
|
||||
|
||||
+31
@@ -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
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user