better eq profile assigning

This commit is contained in:
Boof2015
2026-07-14 13:42:48 -04:00
parent 7b35e01fe0
commit 60a50d68fb
13 changed files with 1125 additions and 158 deletions
@@ -0,0 +1,226 @@
import { useMemo, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { EqSheet } from '@/components/eq/EqSheet';
import type { KnownEQOutputDevice } from '@/audio/eqDevicePresets';
import type { EQPreset } from '@/types/audio';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
interface PresetDeviceAssignmentSheetProps {
preset: EQPreset;
devices: KnownEQOutputDevice[];
assignments: Readonly<Record<string, string>>;
presets: EQPreset[];
currentDeviceKey: string | null;
onSave: (deviceKeys: string[]) => void;
onClose: () => void;
}
function kindLabel(device: KnownEQOutputDevice): string {
switch (device.kind) {
case 'speaker':
return 'Phone speaker';
case 'wired':
return 'Wired output';
case 'bluetooth':
return 'Bluetooth';
case 'usb':
return 'USB audio';
case 'hdmi':
return 'HDMI audio';
default:
return 'Audio output';
}
}
/** Optional Poweramp-style automation: one saved preset may own many devices. */
export function PresetDeviceAssignmentSheet({
preset,
devices,
assignments,
presets,
currentDeviceKey,
onSave,
onClose,
}: PresetDeviceAssignmentSheetProps) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const [selected, setSelected] = useState<Set<string>>(
() => new Set(devices.filter((device) => assignments[device.key] === preset.id).map((device) => device.key))
);
const presetNames = useMemo(
() => new Map(presets.map((candidate) => [candidate.id, candidate.name])),
[presets]
);
const sortedDevices = useMemo(
() => [...devices].sort((a, b) => {
if (a.key === currentDeviceKey) return -1;
if (b.key === currentDeviceKey) return 1;
return b.lastSeenAt - a.lastSeenAt || a.label.localeCompare(b.label);
}),
[currentDeviceKey, devices]
);
const toggleDevice = (deviceKey: string) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(deviceKey)) next.delete(deviceKey);
else next.add(deviceKey);
return next;
});
playHaptic('selection');
};
return (
<EqSheet onClose={onClose} scrollable>
<Text variant="heading" style={styles.title}>
Assign {preset.name}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.description}>
This preset will load automatically when a selected output becomes active.
</Text>
{sortedDevices.length === 0 ? (
<Text variant="body" color={colors.textTertiary} style={styles.empty}>
No audio outputs have been observed yet.
</Text>
) : (
<View style={styles.list}>
{sortedDevices.map((device) => {
const checked = selected.has(device.key);
const assignedPresetId = assignments[device.key];
const assignedPresetName = assignedPresetId ? presetNames.get(assignedPresetId) : null;
const subtitle = assignedPresetId === preset.id
? 'Assigned to this preset'
: assignedPresetName
? `Currently assigned to ${assignedPresetName}`
: kindLabel(device);
return (
<Pressable
key={device.key}
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={styles.deviceRow}
onPress={() => toggleDevice(device.key)}
accessibilityRole="checkbox"
accessibilityState={{ checked }}
accessibilityLabel={`${device.label}${device.key === currentDeviceKey ? ', current output' : ''}`}
>
<View style={styles.deviceMeta}>
<View style={styles.deviceTitleRow}>
<Text variant="body" numberOfLines={1} style={styles.deviceTitle}>
{device.label}
</Text>
{device.key === currentDeviceKey ? (
<Text variant="caption" color={colors.accentTextStrong} style={styles.currentBadge}>
CURRENT
</Text>
) : null}
</View>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{subtitle}
</Text>
</View>
<Ionicons
name={checked ? 'checkbox' : 'square-outline'}
size={22}
color={checked ? colors.accent : colors.textTertiary}
/>
</Pressable>
);
})}
</View>
)}
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={[styles.button, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>Cancel</Text>
</Pressable>
<Pressable
android_ripple={ripple.bounded}
style={[styles.button, styles.save]}
onPress={() => {
onSave([...selected]);
onClose();
}}
>
<Text variant="label" color={colors.accentTextStrong}>Save assignments</Text>
</Pressable>
</View>
</EqSheet>
);
}
const useStyles = createThemedStyles((colors) => ({
title: {
marginTop: spacing.xs,
},
description: {
lineHeight: 17,
marginTop: spacing.xs,
marginBottom: spacing.md,
},
empty: {
paddingVertical: spacing.xl,
},
list: {
gap: spacing.xs,
},
deviceRow: {
minHeight: 60,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.glassBg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
overflow: 'hidden',
},
deviceMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
deviceTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
deviceTitle: {
flexShrink: 1,
},
currentBadge: {
fontSize: 10,
letterSpacing: 0.7,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg,
},
button: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
borderRadius: radius.pill,
},
cancel: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
save: {
backgroundColor: colors.accentGlow,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.accent,
},
}));
export default PresetDeviceAssignmentSheet;
+82 -38
View File
@@ -1,9 +1,10 @@
import { Pressable, StyleSheet } from 'react-native';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import type { KnownEQOutputDevice } from '@/audio/eqDevicePresets';
import type { EQPreset } from '@/types/audio';
import {
EqSheet,
@@ -14,8 +15,11 @@ import {
interface PresetSheetProps {
presets: EQPreset[];
activePresetId: string | null;
knownDevices: KnownEQOutputDevice[];
assignments: Readonly<Record<string, string>>;
onApply: (id: string) => void;
onDelete: (id: string) => void;
onAssign: (preset: EQPreset) => void;
onDelete: (preset: EQPreset) => void;
onSaveNew: () => void;
onClose: () => void;
}
@@ -32,7 +36,10 @@ function modeIcon(preset: EQPreset): 'options-outline' | 'analytics-outline' {
export function PresetSheet({
presets,
activePresetId,
knownDevices,
assignments,
onApply,
onAssign,
onDelete,
onSaveNew,
onClose,
@@ -41,25 +48,69 @@ export function PresetSheet({
const ripple = useRipple();
const builtIn = presets.filter((p) => !p.isCustom);
const custom = presets.filter((p) => p.isCustom);
const devicesByKey = new Map(knownDevices.map((device) => [device.key, device]));
const renderPreset = (preset: EQPreset) => {
const assignedDeviceKeys = Object.entries(assignments)
.filter(([, presetId]) => presetId === preset.id)
.map(([deviceKey]) => deviceKey);
const assignmentSubtitle = assignedDeviceKeys.length === 1
? `Assigned to ${devicesByKey.get(assignedDeviceKeys[0])?.label ?? '1 device'}`
: assignedDeviceKeys.length > 1
? `Assigned to ${assignedDeviceKeys.length} devices`
: undefined;
return (
<EqSheetItem
key={preset.id}
label={preset.name}
subtitle={assignmentSubtitle}
icon={preset.isCustom ? modeIcon(preset) : undefined}
selected={preset.id === activePresetId}
onPress={() => {
onApply(preset.id);
onClose();
}}
trailing={
<View style={styles.trailingActions}>
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={6}
onPress={() => onAssign(preset)}
style={styles.assignButton}
accessibilityLabel={`Assign devices to ${preset.name}`}
>
<Text variant="label" color={colors.textSecondary}>
Assign
</Text>
<Ionicons name="chevron-forward" size={14} color={colors.textTertiary} />
</Pressable>
{preset.isCustom ? (
<Pressable
android_ripple={ripple.bounded}
unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={8}
onPress={() => onDelete(preset)}
style={styles.deleteButton}
accessibilityLabel={`Delete preset ${preset.name}`}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
) : null}
</View>
}
/>
);
};
return (
<EqSheet onClose={onClose}>
<EqSheet onClose={onClose} scrollable>
<Text variant="heading" style={styles.title}>
Presets
</Text>
<EqSheetSection label="BUILT-IN" />
{builtIn.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
/>
))}
{builtIn.map(renderPreset)}
<EqSheetSection label="CUSTOM" />
{custom.length === 0 ? (
@@ -67,28 +118,7 @@ export function PresetSheet({
No saved presets yet.
</Text>
) : (
custom.map((p) => (
<EqSheetItem
key={p.id}
label={p.name}
icon={modeIcon(p)}
selected={p.id === activePresetId}
onPress={() => {
onApply(p.id);
onClose();
}}
trailing={
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY}
hitSlop={10}
onPress={() => onDelete(p.id)}
style={styles.delete}
accessibilityLabel={`Delete preset ${p.name}`}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
}
/>
))
custom.map(renderPreset)
)}
<EqSheetItem
@@ -110,7 +140,21 @@ const styles = StyleSheet.create({
empty: {
paddingVertical: spacing.sm,
},
delete: {
trailingActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
assignButton: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: 2,
paddingHorizontal: spacing.sm,
borderRadius: radius.pill,
overflow: 'hidden',
},
deleteButton: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
},
+44 -4
View File
@@ -6,6 +6,7 @@ import {
} from 'react-native';
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
import { Text } from '@/components/Text';
import { HapticSwitch } from '@/components/HapticSwitch';
import {
fonts,
radius,
@@ -17,16 +18,23 @@ import { EqSheet } from './EqSheet';
interface SavePresetSheetProps {
defaultName: string;
onSave: (name: string) => void;
currentDeviceLabel?: string | null;
onSave: (name: string, assignToCurrentDevice: boolean) => void;
onClose: () => void;
}
/** Name + save a custom preset from the current bands/preamp. */
export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) {
export function SavePresetSheet({
defaultName,
currentDeviceLabel,
onSave,
onClose,
}: SavePresetSheetProps) {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const [name, setName] = useState(defaultName);
const [assignToCurrentDevice, setAssignToCurrentDevice] = useState(false);
const trimmed = name.trim();
return (
@@ -46,11 +54,27 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee
returnKeyType="done"
onSubmitEditing={() => {
if (trimmed) {
onSave(trimmed);
onSave(trimmed, assignToCurrentDevice);
onClose();
}
}}
/>
{currentDeviceLabel ? (
<View style={styles.assignmentRow}>
<View style={styles.assignmentText}>
<Text variant="body">Assign to current output</Text>
<Text variant="caption" color={colors.textSecondary} numberOfLines={1}>
{currentDeviceLabel}
</Text>
</View>
<HapticSwitch
value={assignToCurrentDevice}
onValueChange={setAssignToCurrentDevice}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
) : null}
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={[styles.btn, styles.cancel]} onPress={onClose}>
<Text variant="label" color={colors.textSecondary}>
@@ -61,7 +85,7 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee
style={[styles.btn, styles.save, !trimmed && styles.saveDisabled]}
disabled={!trimmed}
onPress={() => {
onSave(trimmed);
onSave(trimmed, assignToCurrentDevice);
onClose();
}}
>
@@ -90,6 +114,22 @@ const useStyles = createThemedStyles((colors) => ({
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
assignmentRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
marginTop: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
assignmentText: {
flex: 1,
minWidth: 0,
gap: 2,
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
+41 -7
View File
@@ -7,6 +7,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetScrollView,
BottomSheetView,
type BottomSheetBackdropProps
} from '@gorhom/bottom-sheet';
@@ -19,7 +20,15 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
import { playHaptic } from '@/lib/haptics';
export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) {
export function AppSheet({
onClose,
children,
scrollable = false,
}: {
onClose: () => void;
children: ReactNode;
scrollable?: boolean;
}) {
const styles = useStyles();
const insets = useSafeAreaInsets();
const renderBackdrop = useCallback(
@@ -45,9 +54,20 @@ export function AppSheet({ onClose, children }: { onClose: () => void; children:
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
<BottomSheetView style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}>
{children}
</BottomSheetView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<BottomSheetView
style={[styles.content, { paddingBottom: insets.bottom + spacing.md }]}
>
{children}
</BottomSheetView>
)}
</BottomSheet>
);
}
@@ -80,6 +100,7 @@ export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: s
export interface AppSheetItemProps {
label: string;
subtitle?: string;
icon?: keyof typeof Ionicons.glyphMap;
selected?: boolean;
destructive?: boolean;
@@ -89,6 +110,7 @@ export interface AppSheetItemProps {
export function AppSheetItem({
label,
subtitle,
icon,
selected,
destructive,
@@ -118,9 +140,16 @@ export function AppSheetItem({
{icon ? (
<Ionicons name={icon} size={20} color={destructive ? colors.warning : colors.textSecondary} />
) : null}
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
<View style={styles.itemMeta}>
<Text variant="body" numberOfLines={1} style={styles.itemLabel} color={tint}>
{label}
</Text>
{subtitle ? (
<Text variant="caption" numberOfLines={1} color={colors.textSecondary}>
{subtitle}
</Text>
) : null}
</View>
{selected ? <Ionicons name="checkmark" size={18} color={colors.accent} /> : null}
</Pressable>
{trailing}
@@ -168,7 +197,12 @@ const useStyles = createThemedStyles((colors) => ({
paddingVertical: spacing.md,
},
itemLabel: {
flexShrink: 1,
},
itemMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
}));