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 device = selectOutputDevice()
|
||||||
val kind = device?.let { kindForType(it.type) } ?: "unknown"
|
val kind = device?.let { kindForType(it.type) } ?: "unknown"
|
||||||
val label = displayLabel(kind, device, selectedRouteName)
|
val label = displayLabel(kind, device, selectedRouteName)
|
||||||
val key = routeKey(kind, label)
|
val key = buildOutputRouteKey(kind, label, deviceAddress(device))
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"key" to key,
|
"key" to key,
|
||||||
@@ -214,6 +214,17 @@ class AstraAudioRouteModule : Module() {
|
|||||||
return routeName ?: deviceName ?: defaultLabel(kind)
|
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 =
|
private fun defaultLabel(kind: String): String =
|
||||||
when (kind) {
|
when (kind) {
|
||||||
"speaker" -> "Phone speaker"
|
"speaker" -> "Phone speaker"
|
||||||
@@ -244,27 +255,4 @@ class AstraAudioRouteModule : Module() {
|
|||||||
return kind == "bluetooth" || kind == "usb" || kind == "hdmi"
|
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
|
package expo.modules.astraaudioroute
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/** Prefer Android's media-attribute prediction, then retain the legacy fallback. */
|
/** Prefer Android's media-attribute prediction, then retain the legacy fallback. */
|
||||||
internal fun <T> selectPredictedOutputDevice(
|
internal fun <T> selectPredictedOutputDevice(
|
||||||
predicted: List<T>,
|
predicted: List<T>,
|
||||||
@@ -15,3 +17,41 @@ internal fun <T> selectPredictedOutputDevice(
|
|||||||
?: connected.firstOrNull { kindFor(it) == "speaker" }
|
?: connected.firstOrNull { kindFor(it) == "speaker" }
|
||||||
?: connected.first()
|
?: 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
|
package expo.modules.astraaudioroute
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class OutputDeviceSelectorTest {
|
class OutputDeviceSelectorTest {
|
||||||
@@ -34,4 +36,33 @@ class OutputDeviceSelectorTest {
|
|||||||
|
|
||||||
assertEquals(bluetooth, selected)
|
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 { EQPresetNameSheet } from '@/components/eq/EQPresetNameSheet';
|
||||||
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
||||||
import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet';
|
import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet';
|
||||||
|
import { PresetDeviceAssignmentSheet } from '@/components/eq/PresetDeviceAssignmentSheet';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -73,7 +74,16 @@ import {
|
|||||||
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
|
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
|
||||||
import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
|
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 CurrentPresetAction = 'export' | 'share' | 'qr';
|
||||||
type EQState = ReturnType<typeof useEQStore.getState>;
|
type EQState = ReturnType<typeof useEQStore.getState>;
|
||||||
|
|
||||||
@@ -99,6 +109,7 @@ export default function EQScreen() {
|
|||||||
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
||||||
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
||||||
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | 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 closeSheet = useCallback(() => setSheet('none'), []);
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -128,6 +139,20 @@ export default function EQScreen() {
|
|||||||
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
|
const activeBandNumber = eq.bands.findIndex((b) => b.id === eq.activeBandId) + 1;
|
||||||
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
|
const presetName = eq.presets.find((p) => p.id === eq.activePresetId)?.name ?? 'Custom';
|
||||||
const outputRouteLabel = eq.activeOutputRoute?.label ?? 'This phone';
|
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 defaultPresetName = `Preset ${eq.presets.filter((p) => p.isCustom).length + 1}`;
|
||||||
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
|
const valueEditConfig = activeBand && editingValue ? getValueEditConfig(editingValue, activeBand) : null;
|
||||||
|
|
||||||
@@ -149,6 +174,30 @@ export default function EQScreen() {
|
|||||||
Alert.alert('Could not import preset', message);
|
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 runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
|
||||||
const preset = buildCurrentEQPreset(useEQStore.getState(), name);
|
const preset = buildCurrentEQPreset(useEQStore.getState(), name);
|
||||||
try {
|
try {
|
||||||
@@ -345,7 +394,7 @@ export default function EQScreen() {
|
|||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
style={styles.routeLabel}
|
style={styles.routeLabel}
|
||||||
>
|
>
|
||||||
{`Tuning ${outputRouteLabel}`}
|
{`${outputRouteLabel} · ${outputAssignmentLabel}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerActions}>
|
<View style={styles.headerActions}>
|
||||||
@@ -400,8 +449,14 @@ export default function EQScreen() {
|
|||||||
<PresetSheet
|
<PresetSheet
|
||||||
presets={eq.presets}
|
presets={eq.presets}
|
||||||
activePresetId={eq.activePresetId}
|
activePresetId={eq.activePresetId}
|
||||||
|
knownDevices={Object.values(eq.knownOutputDevices)}
|
||||||
|
assignments={eq.devicePresetAssignments}
|
||||||
onApply={eq.applyPreset}
|
onApply={eq.applyPreset}
|
||||||
onDelete={eq.deleteCustomPreset}
|
onAssign={(preset) => {
|
||||||
|
setActionPresetId(preset.id);
|
||||||
|
setSheet('assignDevices');
|
||||||
|
}}
|
||||||
|
onDelete={deletePreset}
|
||||||
onSaveNew={() => setSheet('save')}
|
onSaveNew={() => setSheet('save')}
|
||||||
onClose={closeSheet}
|
onClose={closeSheet}
|
||||||
/>
|
/>
|
||||||
@@ -410,7 +465,33 @@ export default function EQScreen() {
|
|||||||
{sheet === 'save' ? (
|
{sheet === 'save' ? (
|
||||||
<SavePresetSheet
|
<SavePresetSheet
|
||||||
defaultName={defaultPresetName}
|
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}
|
onClose={closeSheet}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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', () => {
|
test('normalizes route keys for named and unnamed Bluetooth outputs', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Sony WH-1000XM5' })?.key,
|
normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Sony WH-1000XM5' })?.key,
|
||||||
'bluetooth:sony-wh-1000xm5'
|
'bluetooth:name:sony-wh-1000xm5'
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeAudioOutputRoute({ kind: 'bluetooth', label: 'Bluetooth audio' })?.key,
|
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: '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: 'speaker', label: 'Pixel speaker' })?.key, 'speaker');
|
||||||
assert.equal(normalizeAudioOutputRoute({ kind: 'nonsense', label: '' })?.key, 'unknown');
|
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);
|
const normalized = slug(label);
|
||||||
return (
|
if (normalized.length === 0) return true;
|
||||||
normalized.length === 0 ||
|
if (kind === 'bluetooth') {
|
||||||
normalized === 'bluetooth' ||
|
return (
|
||||||
normalized === 'bluetooth-audio' ||
|
normalized === 'bluetooth' ||
|
||||||
normalized === 'headphones' ||
|
normalized === 'bluetooth-audio' ||
|
||||||
normalized === 'headset'
|
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 {
|
export function buildAudioOutputRouteKey(kind: AudioOutputRouteKind, label: string | null): string {
|
||||||
if (kind === 'bluetooth' && label && !isGenericBluetoothLabel(label)) {
|
if (
|
||||||
return `bluetooth:${slug(label)}`;
|
(kind === 'bluetooth' || kind === 'usb' || kind === 'hdmi') &&
|
||||||
|
label &&
|
||||||
|
!isGenericExternalLabel(kind, label)
|
||||||
|
) {
|
||||||
|
return `${kind}:name:${slug(label)}`;
|
||||||
}
|
}
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'speaker':
|
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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { spacing } from '@/theme';
|
import { radius, spacing } from '@/theme';
|
||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
|
import type { KnownEQOutputDevice } from '@/audio/eqDevicePresets';
|
||||||
import type { EQPreset } from '@/types/audio';
|
import type { EQPreset } from '@/types/audio';
|
||||||
import {
|
import {
|
||||||
EqSheet,
|
EqSheet,
|
||||||
@@ -14,8 +15,11 @@ import {
|
|||||||
interface PresetSheetProps {
|
interface PresetSheetProps {
|
||||||
presets: EQPreset[];
|
presets: EQPreset[];
|
||||||
activePresetId: string | null;
|
activePresetId: string | null;
|
||||||
|
knownDevices: KnownEQOutputDevice[];
|
||||||
|
assignments: Readonly<Record<string, string>>;
|
||||||
onApply: (id: string) => void;
|
onApply: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onAssign: (preset: EQPreset) => void;
|
||||||
|
onDelete: (preset: EQPreset) => void;
|
||||||
onSaveNew: () => void;
|
onSaveNew: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -32,7 +36,10 @@ function modeIcon(preset: EQPreset): 'options-outline' | 'analytics-outline' {
|
|||||||
export function PresetSheet({
|
export function PresetSheet({
|
||||||
presets,
|
presets,
|
||||||
activePresetId,
|
activePresetId,
|
||||||
|
knownDevices,
|
||||||
|
assignments,
|
||||||
onApply,
|
onApply,
|
||||||
|
onAssign,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSaveNew,
|
onSaveNew,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -41,25 +48,69 @@ export function PresetSheet({
|
|||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
const builtIn = presets.filter((p) => !p.isCustom);
|
const builtIn = presets.filter((p) => !p.isCustom);
|
||||||
const custom = 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 (
|
return (
|
||||||
<EqSheet onClose={onClose}>
|
<EqSheet onClose={onClose} scrollable>
|
||||||
<Text variant="heading" style={styles.title}>
|
<Text variant="heading" style={styles.title}>
|
||||||
Presets
|
Presets
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<EqSheetSection label="BUILT-IN" />
|
<EqSheetSection label="BUILT-IN" />
|
||||||
{builtIn.map((p) => (
|
{builtIn.map(renderPreset)}
|
||||||
<EqSheetItem
|
|
||||||
key={p.id}
|
|
||||||
label={p.name}
|
|
||||||
selected={p.id === activePresetId}
|
|
||||||
onPress={() => {
|
|
||||||
onApply(p.id);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<EqSheetSection label="CUSTOM" />
|
<EqSheetSection label="CUSTOM" />
|
||||||
{custom.length === 0 ? (
|
{custom.length === 0 ? (
|
||||||
@@ -67,28 +118,7 @@ export function PresetSheet({
|
|||||||
No saved presets yet.
|
No saved presets yet.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
custom.map((p) => (
|
custom.map(renderPreset)
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EqSheetItem
|
<EqSheetItem
|
||||||
@@ -110,7 +140,21 @@ const styles = StyleSheet.create({
|
|||||||
empty: {
|
empty: {
|
||||||
paddingVertical: spacing.sm,
|
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,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
|
import { HapticSwitch } from '@/components/HapticSwitch';
|
||||||
import {
|
import {
|
||||||
fonts,
|
fonts,
|
||||||
radius,
|
radius,
|
||||||
@@ -17,16 +18,23 @@ import { EqSheet } from './EqSheet';
|
|||||||
|
|
||||||
interface SavePresetSheetProps {
|
interface SavePresetSheetProps {
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
onSave: (name: string) => void;
|
currentDeviceLabel?: string | null;
|
||||||
|
onSave: (name: string, assignToCurrentDevice: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Name + save a custom preset from the current bands/preamp. */
|
/** 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 styles = useStyles();
|
||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const [name, setName] = useState(defaultName);
|
const [name, setName] = useState(defaultName);
|
||||||
|
const [assignToCurrentDevice, setAssignToCurrentDevice] = useState(false);
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,11 +54,27 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
onSave(trimmed);
|
onSave(trimmed, assignToCurrentDevice);
|
||||||
onClose();
|
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}>
|
<View style={styles.actions}>
|
||||||
<Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
|
<Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||||
<Text variant="label" color={colors.textSecondary}>
|
<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]}
|
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
|
||||||
disabled={!trimmed}
|
disabled={!trimmed}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSave(trimmed);
|
onSave(trimmed, assignToCurrentDevice);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -90,6 +114,22 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
borderColor: colors.glassBorder,
|
borderColor: colors.glassBorder,
|
||||||
backgroundColor: colors.glassBg,
|
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: {
|
actions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import BottomSheet, {
|
import BottomSheet, {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
|
BottomSheetScrollView,
|
||||||
BottomSheetView,
|
BottomSheetView,
|
||||||
type BottomSheetBackdropProps
|
type BottomSheetBackdropProps
|
||||||
} from '@gorhom/bottom-sheet';
|
} from '@gorhom/bottom-sheet';
|
||||||
@@ -19,7 +20,15 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
|||||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
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 styles = useStyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
@@ -45,9 +54,20 @@ export function AppSheet({ onClose, children }: { onClose: () => void; children:
|
|||||||
backgroundStyle={styles.sheetBg}
|
backgroundStyle={styles.sheetBg}
|
||||||
handleIndicatorStyle={styles.handle}
|
handleIndicatorStyle={styles.handle}
|
||||||
>
|
>
|
||||||
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
|
{scrollable ? (
|
||||||
{children}
|
<BottomSheetScrollView
|
||||||
</BottomSheetView>
|
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
) : (
|
||||||
|
<BottomSheetView
|
||||||
|
style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BottomSheetView>
|
||||||
|
)}
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,6 +100,7 @@ export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: s
|
|||||||
|
|
||||||
export interface AppSheetItemProps {
|
export interface AppSheetItemProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
icon?: keyof typeof Ionicons.glyphMap;
|
icon?: keyof typeof Ionicons.glyphMap;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
@@ -89,6 +110,7 @@ export interface AppSheetItemProps {
|
|||||||
|
|
||||||
export function AppSheetItem({
|
export function AppSheetItem({
|
||||||
label,
|
label,
|
||||||
|
subtitle,
|
||||||
icon,
|
icon,
|
||||||
selected,
|
selected,
|
||||||
destructive,
|
destructive,
|
||||||
@@ -118,9 +140,16 @@ export function AppSheetItem({
|
|||||||
{icon ? (
|
{icon ? (
|
||||||
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
|
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
|
||||||
) : null}
|
) : null}
|
||||||
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
|
<View style={styles.itemMeta}>
|
||||||
{label}
|
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
|
||||||
</Text>
|
{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}
|
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{trailing}
|
{trailing}
|
||||||
@@ -168,7 +197,12 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
itemLabel: {
|
itemLabel: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
itemMeta: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: 2,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+134
-68
@@ -20,13 +20,21 @@ import {
|
|||||||
parseGraphicGains,
|
parseGraphicGains,
|
||||||
} from '@/audio/graphicEq';
|
} from '@/audio/graphicEq';
|
||||||
import {
|
import {
|
||||||
createEQRouteProfile,
|
|
||||||
normalizeAudioOutputRoute,
|
normalizeAudioOutputRoute,
|
||||||
parseEQRouteProfilesJson,
|
parseEQRouteProfilesJson,
|
||||||
restoreEQRouteProfile,
|
|
||||||
stringifyEQRouteProfiles,
|
|
||||||
type EQRouteProfile,
|
|
||||||
} from '@/audio/eqRouteProfiles';
|
} 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';
|
import { setEqBandsNative, setEqEnabledNative, setEqPreampNative } from '@/audio/eqNative';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +51,7 @@ const CUSTOM_PRESETS_KEY = 'eq_custom_presets';
|
|||||||
const MODE_KEY = 'eq_mode';
|
const MODE_KEY = 'eq_mode';
|
||||||
const GRAPHIC_GAINS_KEY = 'eq_graphic_gains';
|
const GRAPHIC_GAINS_KEY = 'eq_graphic_gains';
|
||||||
const ROUTE_PROFILES_KEY = 'eq_route_profiles_v1';
|
const ROUTE_PROFILES_KEY = 'eq_route_profiles_v1';
|
||||||
|
const DEVICE_PRESETS_KEY = 'eq_device_preset_assignments_v1';
|
||||||
|
|
||||||
const PERSIST_DEBOUNCE_MS = 250;
|
const PERSIST_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
@@ -107,6 +116,8 @@ interface EQStore {
|
|||||||
activePresetId: string | null; // null = manually edited ("Custom")
|
activePresetId: string | null; // null = manually edited ("Custom")
|
||||||
activeBandId: string | null; // UI selection shared by curve / strip / panel
|
activeBandId: string | null; // UI selection shared by curve / strip / panel
|
||||||
activeOutputRoute: AudioOutputRoute | null;
|
activeOutputRoute: AudioOutputRoute | null;
|
||||||
|
knownOutputDevices: Record<string, KnownEQOutputDevice>;
|
||||||
|
devicePresetAssignments: Record<string, string>;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
|
|
||||||
load: () => Promise<void>;
|
load: () => Promise<void>;
|
||||||
@@ -121,16 +132,16 @@ interface EQStore {
|
|||||||
selectBand: (id: string | null) => void;
|
selectBand: (id: string | null) => void;
|
||||||
applyPreset: (presetId: string) => void;
|
applyPreset: (presetId: string) => void;
|
||||||
resetToFlat: () => void;
|
resetToFlat: () => void;
|
||||||
saveCustomPreset: (name: string) => void;
|
saveCustomPreset: (name: string) => string;
|
||||||
deleteCustomPreset: (presetId: string) => void;
|
deleteCustomPreset: (presetId: string) => void;
|
||||||
importPreset: (preset: EQPreset) => void;
|
importPreset: (preset: EQPreset) => void;
|
||||||
|
assignPresetToDevices: (presetId: string, deviceKeys: readonly string[]) => void;
|
||||||
setOutputRoute: (route: AudioOutputRoute | null) => Promise<void>;
|
setOutputRoute: (route: AudioOutputRoute | null) => Promise<void>;
|
||||||
|
|
||||||
_syncToNative: () => void;
|
_syncToNative: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let routeProfilesByKey: Record<string, EQRouteProfile> = {};
|
|
||||||
|
|
||||||
export const useEQStore = create<EQStore>((set, get) => {
|
export const useEQStore = create<EQStore>((set, get) => {
|
||||||
function syncToNative(): void {
|
function syncToNative(): void {
|
||||||
@@ -141,24 +152,16 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
setEqBandsNative(flattenBandsForNative(activeBands));
|
setEqBandsNative(flattenBandsForNative(activeBands));
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureRouteProfile(route: AudioOutputRoute | null = get().activeOutputRoute): void {
|
function currentDevicePresetState(): EQDevicePresetState {
|
||||||
if (!route) return;
|
const { knownOutputDevices, devicePresetAssignments } = get();
|
||||||
const { enabled, preamp, mode, bands, graphicGains, activePresetId } = get();
|
return {
|
||||||
routeProfilesByKey = {
|
version: 1,
|
||||||
...routeProfilesByKey,
|
devices: knownOutputDevices,
|
||||||
[route.key]: createEQRouteProfile(route, {
|
assignments: devicePresetAssignments,
|
||||||
enabled,
|
|
||||||
preamp,
|
|
||||||
mode,
|
|
||||||
bands,
|
|
||||||
graphicGains,
|
|
||||||
activePresetId,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersist(): void {
|
function schedulePersist(): void {
|
||||||
captureRouteProfile();
|
|
||||||
if (persistTimer) clearTimeout(persistTimer);
|
if (persistTimer) clearTimeout(persistTimer);
|
||||||
persistTimer = setTimeout(() => {
|
persistTimer = setTimeout(() => {
|
||||||
persistTimer = null;
|
persistTimer = null;
|
||||||
@@ -167,9 +170,23 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function persistNow(): Promise<void> {
|
async function persistNow(): Promise<void> {
|
||||||
captureRouteProfile();
|
const {
|
||||||
const { enabled, preamp, bands, mode, graphicGains, activePresetId, presets } = get();
|
enabled,
|
||||||
|
preamp,
|
||||||
|
bands,
|
||||||
|
mode,
|
||||||
|
graphicGains,
|
||||||
|
activePresetId,
|
||||||
|
presets,
|
||||||
|
knownOutputDevices,
|
||||||
|
devicePresetAssignments,
|
||||||
|
} = get();
|
||||||
const custom = presets.filter((p) => p.isCustom);
|
const custom = presets.filter((p) => p.isCustom);
|
||||||
|
const devicePresetState: EQDevicePresetState = {
|
||||||
|
version: 1,
|
||||||
|
devices: knownOutputDevices,
|
||||||
|
assignments: devicePresetAssignments,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const db = await openLibraryDb();
|
const db = await openLibraryDb();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -179,7 +196,7 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
setSetting(db, MODE_KEY, mode),
|
setSetting(db, MODE_KEY, mode),
|
||||||
setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)),
|
setSetting(db, GRAPHIC_GAINS_KEY, JSON.stringify(graphicGains)),
|
||||||
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
|
setSetting(db, ACTIVE_PRESET_KEY, activePresetId ?? ''),
|
||||||
setSetting(db, ROUTE_PROFILES_KEY, stringifyEQRouteProfiles(routeProfilesByKey)),
|
setSetting(db, DEVICE_PRESETS_KEY, stringifyEQDevicePresetState(devicePresetState)),
|
||||||
setSetting(
|
setSetting(
|
||||||
db,
|
db,
|
||||||
CUSTOM_PRESETS_KEY,
|
CUSTOM_PRESETS_KEY,
|
||||||
@@ -216,12 +233,24 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
activePresetId: FLAT_PRESET_ID,
|
activePresetId: FLAT_PRESET_ID,
|
||||||
activeBandId: null,
|
activeBandId: null,
|
||||||
activeOutputRoute: null,
|
activeOutputRoute: null,
|
||||||
|
knownOutputDevices: {},
|
||||||
|
devicePresetAssignments: {},
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
|
||||||
load: async () => {
|
load: async () => {
|
||||||
if (get().loaded) return;
|
if (get().loaded) return;
|
||||||
const db = await openLibraryDb();
|
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, ENABLED_KEY),
|
||||||
getSetting(db, PREAMP_KEY),
|
getSetting(db, PREAMP_KEY),
|
||||||
getSetting(db, BANDS_KEY),
|
getSetting(db, BANDS_KEY),
|
||||||
@@ -229,13 +258,20 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
getSetting(db, GRAPHIC_GAINS_KEY),
|
getSetting(db, GRAPHIC_GAINS_KEY),
|
||||||
getSetting(db, ACTIVE_PRESET_KEY),
|
getSetting(db, ACTIVE_PRESET_KEY),
|
||||||
getSetting(db, CUSTOM_PRESETS_KEY),
|
getSetting(db, CUSTOM_PRESETS_KEY),
|
||||||
|
getSetting(db, DEVICE_PRESETS_KEY),
|
||||||
getSetting(db, ROUTE_PROFILES_KEY),
|
getSetting(db, ROUTE_PROFILES_KEY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bands = parseBands(bandsRaw) ?? createDefaultBands();
|
const bands = parseBands(bandsRaw) ?? createDefaultBands();
|
||||||
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
|
const presets = [...createBuiltInPresets(), ...parseCustomPresets(customRaw)];
|
||||||
const storedActive = activeRaw && activeRaw.length > 0 ? activeRaw : null;
|
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({
|
set({
|
||||||
enabled: enabledRaw === 'true',
|
enabled: enabledRaw === 'true',
|
||||||
@@ -245,13 +281,24 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
mode: modeRaw === 'graphic' ? 'graphic' : 'parametric',
|
mode: modeRaw === 'graphic' ? 'graphic' : 'parametric',
|
||||||
graphicGains: parseGraphicGains(safeJsonParse(gainsRaw)) ?? createFlatGraphicGains(),
|
graphicGains: parseGraphicGains(safeJsonParse(gainsRaw)) ?? createFlatGraphicGains(),
|
||||||
presets,
|
presets,
|
||||||
// Stored active preset ids are regenerated on load (parseCustomPresets makes
|
// Missing/deleted preset references fall back to the freely editable state.
|
||||||
// new ids), so only built-in ids survive a reload; fall back to "Custom".
|
|
||||||
activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null,
|
activePresetId: presets.some((p) => p.id === storedActive) ? storedActive : null,
|
||||||
activeBandId: bands[0]?.id ?? null,
|
activeBandId: bands[0]?.id ?? null,
|
||||||
|
knownOutputDevices: deviceState.devices,
|
||||||
|
devicePresetAssignments: deviceState.assignments,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
syncToNative();
|
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) => {
|
setEnabled: (enabled) => {
|
||||||
@@ -401,13 +448,16 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
};
|
};
|
||||||
set({ presets: [...presets, preset], activePresetId: preset.id });
|
set({ presets: [...presets, preset], activePresetId: preset.id });
|
||||||
schedulePersist();
|
schedulePersist();
|
||||||
|
return preset.id;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteCustomPreset: (presetId) => {
|
deleteCustomPreset: (presetId) => {
|
||||||
const { presets, activePresetId } = get();
|
const { presets, activePresetId } = get();
|
||||||
|
const deviceState = removePresetDeviceAssignments(currentDevicePresetState(), presetId);
|
||||||
set({
|
set({
|
||||||
presets: presets.filter((p) => p.id !== presetId),
|
presets: presets.filter((p) => p.id !== presetId),
|
||||||
activePresetId: activePresetId === presetId ? null : activePresetId,
|
activePresetId: activePresetId === presetId ? null : activePresetId,
|
||||||
|
devicePresetAssignments: deviceState.assignments,
|
||||||
});
|
});
|
||||||
schedulePersist();
|
schedulePersist();
|
||||||
},
|
},
|
||||||
@@ -426,59 +476,75 @@ export const useEQStore = create<EQStore>((set, get) => {
|
|||||||
get().applyPreset(stored.id);
|
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) => {
|
setOutputRoute: async (route) => {
|
||||||
if (!get().loaded) await get().load();
|
if (!get().loaded) await get().load();
|
||||||
|
|
||||||
const nextRoute = normalizeAudioOutputRoute(route);
|
const nextRoute = normalizeAudioOutputRoute(route);
|
||||||
const previousRoute = get().activeOutputRoute;
|
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) {
|
if (!nextRoute) {
|
||||||
set({ activeOutputRoute: null });
|
if (previousRoute) set({ activeOutputRoute: null });
|
||||||
schedulePersist();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = routeProfilesByKey[nextRoute.key];
|
const previousDeviceState = currentDevicePresetState();
|
||||||
if (!profile) {
|
const observedDeviceState = observeEQOutputDevice(previousDeviceState, nextRoute);
|
||||||
// First-seen routes inherit whatever EQ is currently audible, then diverge
|
const deviceStateChanged = observedDeviceState !== previousDeviceState;
|
||||||
// as soon as the user edits while this route is active.
|
const activeRouteChanged =
|
||||||
set({ activeOutputRoute: nextRoute });
|
previousRoute?.key !== nextRoute.key ||
|
||||||
schedulePersist();
|
previousRoute.label !== nextRoute.label ||
|
||||||
return;
|
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) =>
|
const assignedPresetId = presetForOutputRouteTransition(
|
||||||
get().presets.some((preset) => preset.id === presetId)
|
previousRoute?.key ?? null,
|
||||||
|
nextRoute.key,
|
||||||
|
observedDeviceState.assignments,
|
||||||
|
(presetId) => get().presets.some((preset) => preset.id === presetId)
|
||||||
);
|
);
|
||||||
set({
|
if (assignedPresetId) {
|
||||||
activeOutputRoute: nextRoute,
|
// applyPreset intentionally does not touch `enabled`, so EQ power stays global.
|
||||||
enabled: restored.enabled,
|
get().applyPreset(assignedPresetId);
|
||||||
preamp: restored.preamp,
|
}
|
||||||
mode: restored.mode,
|
|
||||||
bands: restored.bands,
|
|
||||||
graphicGains: restored.graphicGains,
|
|
||||||
activePresetId: restored.activePresetId,
|
|
||||||
activeBandId: restored.bands[0]?.id ?? null,
|
|
||||||
});
|
|
||||||
syncToNative();
|
|
||||||
schedulePersist();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_syncToNative: syncToNative,
|
_syncToNative: syncToNative,
|
||||||
|
|||||||
Reference in New Issue
Block a user