mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
sharable eq
This commit is contained in:
+198
-4
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
InteractionManager,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
@@ -7,10 +8,16 @@ import {
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { readAsStringAsync } from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import {
|
||||
StorageAccessFramework,
|
||||
cacheDirectory,
|
||||
readAsStringAsync,
|
||||
writeAsStringAsync,
|
||||
} from 'expo-file-system/legacy';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQGraph } from '@/components/eq/EQGraph';
|
||||
@@ -23,6 +30,9 @@ import { EQValueEditSheet } from '@/components/eq/EQValueEditSheet';
|
||||
import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
|
||||
import { PresetSheet } from '@/components/eq/PresetSheet';
|
||||
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
|
||||
import { EQPresetNameSheet } from '@/components/eq/EQPresetNameSheet';
|
||||
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
||||
import { EQPresetQrSheet } from '@/components/eq/EQPresetQrSheet';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
@@ -44,21 +54,36 @@ import {
|
||||
isPassEQBandType
|
||||
} from '@/audio/eq';
|
||||
import { parseAutoEQ } from '@/audio/autoEQParser';
|
||||
import { buildGraphicBands } from '@/audio/graphicEq';
|
||||
import { genEqId } from '@/audio/eqPresets';
|
||||
import {
|
||||
EQ_PRESET_MIME_TYPE,
|
||||
buildEQPresetFileName,
|
||||
encodeEQPresetQr,
|
||||
parseEQPresetFileContents,
|
||||
stringifyEQPresetFileContents,
|
||||
} from '@/audio/eqShare';
|
||||
import { BAND_TYPE_LABEL, formatGain } from '@/components/eq/format';
|
||||
import type { EQBand, EQBandType } from '@/types/audio';
|
||||
import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
|
||||
|
||||
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
|
||||
type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type' | 'shareName' | 'qr' | 'preview';
|
||||
type CurrentPresetAction = 'export' | 'share' | 'qr';
|
||||
type EQState = ReturnType<typeof useEQStore.getState>;
|
||||
|
||||
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
|
||||
|
||||
export default function EQScreen() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const eq = useEQStore();
|
||||
const scopeActive = useScopeActive();
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [sheet, setSheet] = useState<SheetKind>('none');
|
||||
const [editingValue, setEditingValue] = useState<EQEditableValue | null>(null);
|
||||
const [pendingCurrentAction, setPendingCurrentAction] = useState<CurrentPresetAction | null>(null);
|
||||
const [pendingImportPreset, setPendingImportPreset] = useState<EQPreset | null>(null);
|
||||
const [qrPreset, setQrPreset] = useState<{ name: string; value: string } | null>(null);
|
||||
const closeSheet = useCallback(() => setSheet('none'), []);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -103,6 +128,91 @@ export default function EQScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const showPresetImportError = useCallback((message = 'That file is not an Astra EQ preset.') => {
|
||||
Alert.alert('Could not import preset', message);
|
||||
}, []);
|
||||
|
||||
const runCurrentPresetAction = useCallback(async (action: CurrentPresetAction, name: string) => {
|
||||
const preset = buildCurrentEQPreset(useEQStore.getState(), name);
|
||||
try {
|
||||
if (action === 'export') {
|
||||
const permission = await StorageAccessFramework.requestDirectoryPermissionsAsync();
|
||||
if (!permission.granted) return;
|
||||
const fileName = buildEQPresetFileName(preset.name);
|
||||
const fileUri = await StorageAccessFramework.createFileAsync(
|
||||
permission.directoryUri,
|
||||
fileName,
|
||||
EQ_PRESET_MIME_TYPE
|
||||
);
|
||||
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
|
||||
Alert.alert('Preset exported', `Saved ${fileName}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'share') {
|
||||
const available = await Sharing.isAvailableAsync();
|
||||
if (!available) {
|
||||
Alert.alert('Share unavailable', 'This device cannot open a share sheet right now.');
|
||||
return;
|
||||
}
|
||||
if (!cacheDirectory) {
|
||||
Alert.alert('Share unavailable', 'Astra could not create a temporary preset file.');
|
||||
return;
|
||||
}
|
||||
const fileUri = `${cacheDirectory}${buildEQPresetFileName(preset.name)}`;
|
||||
await writeAsStringAsync(fileUri, stringifyEQPresetFileContents(preset));
|
||||
await Sharing.shareAsync(fileUri, {
|
||||
mimeType: EQ_PRESET_MIME_TYPE,
|
||||
dialogTitle: `Share ${preset.name}`,
|
||||
UTI: 'public.json',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setQrPreset({ name: preset.name, value: encodeEQPresetQr(preset) });
|
||||
setSheet('qr');
|
||||
} catch {
|
||||
Alert.alert('Preset sharing failed', 'Astra could not finish that preset sharing action.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCurrentPresetAction = useCallback(
|
||||
(action: CurrentPresetAction) => {
|
||||
const activeName = getActivePresetName(useEQStore.getState());
|
||||
if (activeName) {
|
||||
closeSheet();
|
||||
void runCurrentPresetAction(action, activeName);
|
||||
return;
|
||||
}
|
||||
setPendingCurrentAction(action);
|
||||
setSheet('shareName');
|
||||
},
|
||||
[closeSheet, runCurrentPresetAction]
|
||||
);
|
||||
|
||||
const handleNamedCurrentPresetAction = useCallback(
|
||||
(name: string) => {
|
||||
const action = pendingCurrentAction;
|
||||
setPendingCurrentAction(null);
|
||||
if (!action) return;
|
||||
void runCurrentPresetAction(action, name);
|
||||
},
|
||||
[pendingCurrentAction, runCurrentPresetAction]
|
||||
);
|
||||
|
||||
const handleImportAstraPreset = async () => {
|
||||
closeSheet();
|
||||
try {
|
||||
const res = await DocumentPicker.getDocumentAsync({ type: '*/*', copyToCacheDirectory: true });
|
||||
if (res.canceled || !res.assets?.[0]) return;
|
||||
const content = await readAsStringAsync(res.assets[0].uri);
|
||||
setPendingImportPreset(parseEQPresetFileContents(content, genEqId));
|
||||
setSheet('preview');
|
||||
} catch (error) {
|
||||
showPresetImportError(error instanceof Error ? error.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const presetRowEl = (
|
||||
<Pressable
|
||||
style={[styles.presetRow, isWide && styles.sideItem]}
|
||||
@@ -266,6 +376,18 @@ export default function EQScreen() {
|
||||
|
||||
{sheet === 'overflow' ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<EqSheetItem label="Export preset..." icon="folder-outline" onPress={() => startCurrentPresetAction('export')} />
|
||||
<EqSheetItem label="Share preset..." icon="share-outline" onPress={() => startCurrentPresetAction('share')} />
|
||||
<EqSheetItem label="Show preset QR..." icon="qr-code-outline" onPress={() => startCurrentPresetAction('qr')} />
|
||||
<EqSheetItem label="Import Astra preset..." icon="download-outline" onPress={handleImportAstraPreset} />
|
||||
<EqSheetItem
|
||||
label="Scan preset QR..."
|
||||
icon="scan-outline"
|
||||
onPress={() => {
|
||||
closeSheet();
|
||||
router.push('/eq/scan' as never);
|
||||
}}
|
||||
/>
|
||||
<EqSheetItem label="Import AutoEQ…" icon="download-outline" onPress={handleImportAutoEQ} />
|
||||
{!isGraphic && eq.bands.length > 1 && activeBand ? (
|
||||
<EqSheetItem
|
||||
@@ -289,6 +411,40 @@ export default function EQScreen() {
|
||||
</EqSheet>
|
||||
) : null}
|
||||
|
||||
{sheet === 'shareName' && pendingCurrentAction ? (
|
||||
<EQPresetNameSheet
|
||||
defaultName={defaultPresetName}
|
||||
actionLabel={getCurrentActionLabel(pendingCurrentAction)}
|
||||
onSubmit={handleNamedCurrentPresetAction}
|
||||
onClose={closeSheet}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'qr' && qrPreset ? (
|
||||
<EQPresetQrSheet
|
||||
presetName={qrPreset.name}
|
||||
value={qrPreset.value}
|
||||
onClose={() => {
|
||||
setQrPreset(null);
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'preview' && pendingImportPreset ? (
|
||||
<EQPresetPreviewSheet
|
||||
preset={pendingImportPreset}
|
||||
onConfirm={() => {
|
||||
eq.importPreset(pendingImportPreset);
|
||||
setPendingImportPreset(null);
|
||||
}}
|
||||
onClose={() => {
|
||||
setPendingImportPreset(null);
|
||||
closeSheet();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sheet === 'type' && activeBand ? (
|
||||
<EqSheet onClose={closeSheet}>
|
||||
<Text variant="heading" style={styles.sheetTitle}>
|
||||
@@ -396,6 +552,44 @@ function parsePlainNumber(value: string): number | null {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getActivePresetName(eq: EQState): string | null {
|
||||
if (!eq.activePresetId) return null;
|
||||
const name = eq.presets.find((preset) => preset.id === eq.activePresetId)?.name.trim();
|
||||
return name && name.length > 0 ? name : null;
|
||||
}
|
||||
|
||||
function buildCurrentEQPreset(eq: EQState, name: string): EQPreset {
|
||||
if (eq.mode === 'graphic') {
|
||||
return {
|
||||
id: 'current-eq',
|
||||
name,
|
||||
preamp: eq.preamp,
|
||||
bands: buildGraphicBands(eq.graphicGains).map((band) => ({ ...band })),
|
||||
mode: 'graphic',
|
||||
graphicGains: [...eq.graphicGains],
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'current-eq',
|
||||
name,
|
||||
preamp: eq.preamp,
|
||||
bands: eq.bands.map((band) => ({ ...band })),
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentActionLabel(action: CurrentPresetAction): string {
|
||||
switch (action) {
|
||||
case 'export':
|
||||
return 'Export';
|
||||
case 'share':
|
||||
return 'Share';
|
||||
case 'qr':
|
||||
return 'Show QR';
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import {
|
||||
CameraView,
|
||||
useCameraPermissions,
|
||||
type BarcodeScanningResult
|
||||
} from 'expo-camera';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
||||
import { decodeEQPresetQr } from '@/audio/eqShare';
|
||||
import { genEqId } from '@/audio/eqPresets';
|
||||
import { useEQStore } from '@/stores/eqStore';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import type { EQPreset } from '@/types/audio';
|
||||
|
||||
export default function EQPresetScanScreen() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const importPreset = useEQStore((state) => state.importPreset);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const locked = pendingPreset !== null || error !== null;
|
||||
|
||||
const onScanned = (result: BarcodeScanningResult) => {
|
||||
if (locked) return;
|
||||
const data = result.data?.trim();
|
||||
if (!data) return;
|
||||
try {
|
||||
setPendingPreset(decodeEQPresetQr(data, genEqId));
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('That QR does not contain an Astra EQ preset.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
Equalizer
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text variant="title" style={styles.heading}>
|
||||
Scan EQ preset QR
|
||||
</Text>
|
||||
|
||||
{!permission ? (
|
||||
<View style={styles.center} />
|
||||
) : !permission.granted ? (
|
||||
<View style={styles.permissionCard}>
|
||||
<Ionicons name="camera-outline" size={28} color={colors.accent} />
|
||||
<Text variant="body">Camera access is needed to scan EQ preset QR codes.</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
|
||||
<Text variant="body" color={colors.accentTextStrong}>
|
||||
Allow camera
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.scannerFrame}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={locked ? undefined : onScanned}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scanBox} />
|
||||
{error ? (
|
||||
<View style={styles.errorPanel}>
|
||||
<View style={styles.errorText}>
|
||||
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
|
||||
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable style={styles.retryButton} onPress={() => setError(null)}>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Scan again
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{pendingPreset ? (
|
||||
<EQPresetPreviewSheet
|
||||
preset={pendingPreset}
|
||||
title="Scanned preset"
|
||||
onConfirm={() => {
|
||||
importPreset(pendingPreset);
|
||||
setPendingPreset(null);
|
||||
router.replace('/eq' as never);
|
||||
}}
|
||||
onClose={() => setPendingPreset(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
header: {
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
back: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
heading: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
},
|
||||
permissionCard: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.md,
|
||||
},
|
||||
primaryButton: {
|
||||
minHeight: 44,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scannerFrame: {
|
||||
flex: 1,
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgSecondary,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
scanBox: {
|
||||
position: 'absolute',
|
||||
left: '15%',
|
||||
right: '15%',
|
||||
top: '25%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
errorPanel: {
|
||||
position: 'absolute',
|
||||
left: spacing.lg,
|
||||
right: spacing.lg,
|
||||
bottom: spacing.lg,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.bgSecondary,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
padding: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
errorText: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorCopy: {
|
||||
flex: 1,
|
||||
},
|
||||
retryButton: {
|
||||
alignSelf: 'flex-end',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
}));
|
||||
+53
-10
@@ -3,7 +3,7 @@
|
||||
// in the EQ screen. Coefficients themselves are computed natively (Kotlin) at the
|
||||
// real stream sample rate — here we only flatten band params for the native bridge.
|
||||
|
||||
import type { EQBand, EQBandType, EQPreset } from '@/types/audio';
|
||||
import type { EQBand, EQBandType, EQMode, EQPreset } from '../types/audio';
|
||||
|
||||
export const EQ_MIN_GAIN_DB = -12;
|
||||
export const EQ_MAX_GAIN_DB = 12;
|
||||
@@ -16,6 +16,7 @@ export const EQ_MAX_BANDS = 10;
|
||||
export const EQ_MIN_PREAMP_DB = -12;
|
||||
export const EQ_MAX_PREAMP_DB = 12;
|
||||
export const EQ_PRESET_VERSION = 1;
|
||||
export const EQ_GRAPHIC_BAND_COUNT = 5;
|
||||
|
||||
// Ordinals MUST match the Kotlin `EqBandType` enum order in EqBridge.kt.
|
||||
export const EQ_BAND_TYPE_ORDINAL: Record<EQBandType, number> = {
|
||||
@@ -34,6 +35,17 @@ interface RawEQBand {
|
||||
enabled?: unknown;
|
||||
}
|
||||
|
||||
type SerializedEQBand = Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>;
|
||||
|
||||
export interface SerializedEQPresetData {
|
||||
version: number;
|
||||
name: string;
|
||||
preamp: number;
|
||||
bands: SerializedEQBand[];
|
||||
mode?: EQMode;
|
||||
graphicGains?: number[];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -96,15 +108,36 @@ export function createNormalizedEQBand(rawBand: RawEQBand, id: string): EQBand {
|
||||
return normalizeEQBand(band);
|
||||
}
|
||||
|
||||
function parseEQGraphicGains(value: unknown): number[] | null {
|
||||
if (!Array.isArray(value) || value.length !== EQ_GRAPHIC_BAND_COUNT) return null;
|
||||
const gains: number[] = [];
|
||||
for (const raw of value) {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
gains.push(clampEQGain(parsed));
|
||||
}
|
||||
return gains;
|
||||
}
|
||||
|
||||
export function parseEQPresetData(value: unknown, createId: () => string): EQPreset {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid preset file');
|
||||
}
|
||||
const raw = value as { name?: unknown; preamp?: unknown; bands?: unknown };
|
||||
const raw = value as {
|
||||
version?: unknown;
|
||||
name?: unknown;
|
||||
preamp?: unknown;
|
||||
bands?: unknown;
|
||||
mode?: unknown;
|
||||
graphicGains?: unknown;
|
||||
};
|
||||
if (raw.version !== undefined && raw.version !== EQ_PRESET_VERSION) {
|
||||
throw new Error('Unsupported preset version');
|
||||
}
|
||||
if (typeof raw.name !== 'string' || raw.name.trim().length === 0 || !Array.isArray(raw.bands)) {
|
||||
throw new Error('Invalid preset file');
|
||||
}
|
||||
return {
|
||||
const preset: EQPreset = {
|
||||
id: createId(),
|
||||
name: raw.name.trim(),
|
||||
preamp: clampPreamp(coerceFiniteNumber(raw.preamp, 0)),
|
||||
@@ -118,15 +151,19 @@ export function parseEQPresetData(value: unknown, createId: () => string): EQPre
|
||||
),
|
||||
isCustom: true,
|
||||
};
|
||||
const graphicGains = raw.mode === 'graphic' ? parseEQGraphicGains(raw.graphicGains) : null;
|
||||
if (!graphicGains) return preset;
|
||||
return {
|
||||
...preset,
|
||||
mode: 'graphic',
|
||||
graphicGains,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeEQPresetData(preset: Pick<EQPreset, 'name' | 'preamp' | 'bands'>): {
|
||||
version: number;
|
||||
name: string;
|
||||
preamp: number;
|
||||
bands: Pick<EQBand, 'type' | 'frequency' | 'gain' | 'Q' | 'enabled'>[];
|
||||
} {
|
||||
return {
|
||||
export function serializeEQPresetData(
|
||||
preset: Pick<EQPreset, 'name' | 'preamp' | 'bands' | 'mode' | 'graphicGains'>
|
||||
): SerializedEQPresetData {
|
||||
const data: SerializedEQPresetData = {
|
||||
version: EQ_PRESET_VERSION,
|
||||
name: preset.name,
|
||||
preamp: clampPreamp(coerceFiniteNumber(preset.preamp, 0)),
|
||||
@@ -135,6 +172,12 @@ export function serializeEQPresetData(preset: Pick<EQPreset, 'name' | 'preamp' |
|
||||
return { type: n.type, frequency: n.frequency, gain: n.gain, Q: n.Q, enabled: n.enabled };
|
||||
}),
|
||||
};
|
||||
const graphicGains = preset.mode === 'graphic' ? parseEQGraphicGains(preset.graphicGains) : null;
|
||||
if (graphicGains) {
|
||||
data.mode = 'graphic';
|
||||
data.graphicGains = graphicGains;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { EQBand, EQPreset } from '../types/audio.ts';
|
||||
import { EQ_MAX_BANDS } from './eq.ts';
|
||||
import {
|
||||
EQ_PRESET_QR_PREFIX,
|
||||
decodeEQPresetQr,
|
||||
encodeEQPresetQr,
|
||||
parseEQPresetFileContents,
|
||||
stringifyEQPresetFileContents,
|
||||
} from './eqShare.ts';
|
||||
|
||||
let idCounter = 0;
|
||||
function nextId(): string {
|
||||
idCounter += 1;
|
||||
return `test-eq-${idCounter}`;
|
||||
}
|
||||
|
||||
function band(overrides: Partial<EQBand> = {}): EQBand {
|
||||
return {
|
||||
id: overrides.id ?? nextId(),
|
||||
type: overrides.type ?? 'peaking',
|
||||
frequency: overrides.frequency ?? 1000,
|
||||
gain: overrides.gain ?? 0,
|
||||
Q: overrides.Q ?? 1,
|
||||
enabled: overrides.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
test('round-trips a parametric Astra EQ preset file', () => {
|
||||
const source: EQPreset = {
|
||||
id: 'preset-parametric',
|
||||
name: 'Desk Parametric',
|
||||
preamp: -2.5,
|
||||
bands: [
|
||||
band({ type: 'lowshelf', frequency: 80, gain: 3.5, Q: 0.707 }),
|
||||
band({ type: 'peaking', frequency: 1200, gain: -2, Q: 1.4, enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
const parsed = parseEQPresetFileContents(stringifyEQPresetFileContents(source), nextId);
|
||||
|
||||
assert.equal(parsed.name, 'Desk Parametric');
|
||||
assert.equal(parsed.preamp, -2.5);
|
||||
assert.equal(parsed.mode, undefined);
|
||||
assert.deepEqual(
|
||||
parsed.bands.map((b) => ({ type: b.type, frequency: b.frequency, gain: b.gain, Q: b.Q, enabled: b.enabled })),
|
||||
[
|
||||
{ type: 'lowshelf', frequency: 80, gain: 3.5, Q: 0.707, enabled: true },
|
||||
{ type: 'peaking', frequency: 1200, gain: -2, Q: 1.4, enabled: false },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips a graphic Astra EQ preset file with editable gains', () => {
|
||||
const graphicGains = [-2, 0, 3, 2.5, -1];
|
||||
const source: EQPreset = {
|
||||
id: 'preset-graphic',
|
||||
name: 'Graphic Smile',
|
||||
preamp: -3,
|
||||
mode: 'graphic',
|
||||
graphicGains,
|
||||
bands: graphicGains.map((gain, index) =>
|
||||
band({ id: `graphic-${index}`, frequency: [60, 250, 1000, 4000, 12000][index], gain })
|
||||
),
|
||||
};
|
||||
|
||||
const parsed = parseEQPresetFileContents(stringifyEQPresetFileContents(source), nextId);
|
||||
|
||||
assert.equal(parsed.mode, 'graphic');
|
||||
assert.deepEqual(parsed.graphicGains, graphicGains);
|
||||
assert.equal(parsed.bands.length, 5);
|
||||
});
|
||||
|
||||
test('encodes and decodes a QR payload', () => {
|
||||
const source: EQPreset = {
|
||||
id: 'preset-qr',
|
||||
name: 'QR Preset ✓',
|
||||
preamp: 1,
|
||||
bands: [band({ frequency: 777, gain: 4, Q: 1.25 })],
|
||||
};
|
||||
|
||||
const qr = encodeEQPresetQr(source);
|
||||
assert.ok(qr.startsWith(EQ_PRESET_QR_PREFIX));
|
||||
|
||||
const parsed = decodeEQPresetQr(qr, nextId);
|
||||
assert.equal(parsed.name, 'QR Preset ✓');
|
||||
assert.equal(parsed.bands[0]?.frequency, 777);
|
||||
});
|
||||
|
||||
test('rejects non-Astra and malformed QR payloads', () => {
|
||||
assert.throws(() => decodeEQPresetQr('https://example.com', nextId), /Not an Astra EQ preset QR/);
|
||||
assert.throws(() => decodeEQPresetQr(`${EQ_PRESET_QR_PREFIX}%`, nextId), /Invalid Astra EQ preset QR/);
|
||||
});
|
||||
|
||||
test('rejects invalid preset JSON', () => {
|
||||
assert.throws(() => parseEQPresetFileContents('{not-json', nextId), /Invalid Astra EQ preset file/);
|
||||
assert.throws(
|
||||
() => parseEQPresetFileContents(JSON.stringify({ version: 2, name: 'Future', bands: [] }), nextId),
|
||||
/Unsupported preset version/
|
||||
);
|
||||
});
|
||||
|
||||
test('clamps values and truncates imported bands', () => {
|
||||
const rawBands = Array.from({ length: EQ_MAX_BANDS + 3 }, (_, index) => ({
|
||||
type: index === 0 ? 'highpass' : 'peaking',
|
||||
frequency: index === 0 ? 5 : 50000,
|
||||
gain: 999,
|
||||
Q: 99,
|
||||
enabled: true,
|
||||
}));
|
||||
|
||||
const parsed = parseEQPresetFileContents(JSON.stringify({ version: 1, name: 'Wild', preamp: -99, bands: rawBands }), nextId);
|
||||
|
||||
assert.equal(parsed.preamp, -12);
|
||||
assert.equal(parsed.bands.length, EQ_MAX_BANDS);
|
||||
assert.equal(parsed.bands[0]?.frequency, 20);
|
||||
assert.equal(parsed.bands[0]?.gain, 0);
|
||||
assert.equal(parsed.bands[1]?.frequency, 20000);
|
||||
assert.equal(parsed.bands[1]?.gain, 12);
|
||||
assert.equal(parsed.bands[1]?.Q, 18);
|
||||
});
|
||||
|
||||
test('does not serialize the master EQ enabled state', () => {
|
||||
const source = {
|
||||
id: 'preset-enabled',
|
||||
name: 'No Master State',
|
||||
preamp: 0,
|
||||
enabled: true,
|
||||
bands: [band()],
|
||||
} as EQPreset & { enabled: boolean };
|
||||
|
||||
const raw = JSON.parse(stringifyEQPresetFileContents(source));
|
||||
assert.equal('enabled' in raw, false);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { EQPreset } from '../types/audio';
|
||||
import {
|
||||
parseEQPresetData,
|
||||
serializeEQPresetData,
|
||||
} from './eq.ts';
|
||||
|
||||
export const EQ_PRESET_FILE_EXTENSION = 'astraeq';
|
||||
export const EQ_PRESET_MIME_TYPE = 'application/vnd.astra.eq-preset+json';
|
||||
export const EQ_PRESET_QR_PREFIX = 'astra:eq-preset:v1:';
|
||||
|
||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function stringToUtf8Bytes(value: string): number[] {
|
||||
const bytes: number[] = [];
|
||||
for (const char of value) {
|
||||
const codePoint = char.codePointAt(0) ?? 0;
|
||||
if (codePoint <= 0x7f) {
|
||||
bytes.push(codePoint);
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f));
|
||||
} else if (codePoint <= 0xffff) {
|
||||
bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
|
||||
} else {
|
||||
bytes.push(
|
||||
0xf0 | (codePoint >> 18),
|
||||
0x80 | ((codePoint >> 12) & 0x3f),
|
||||
0x80 | ((codePoint >> 6) & 0x3f),
|
||||
0x80 | (codePoint & 0x3f)
|
||||
);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function utf8BytesToString(bytes: readonly number[]): string {
|
||||
let result = '';
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
const first = bytes[i];
|
||||
if (first <= 0x7f) {
|
||||
result += String.fromCodePoint(first);
|
||||
continue;
|
||||
}
|
||||
if ((first & 0xe0) === 0xc0) {
|
||||
const second = bytes[++i];
|
||||
if (second === undefined) throw new Error('Invalid preset QR');
|
||||
result += String.fromCodePoint(((first & 0x1f) << 6) | (second & 0x3f));
|
||||
continue;
|
||||
}
|
||||
if ((first & 0xf0) === 0xe0) {
|
||||
const second = bytes[++i];
|
||||
const third = bytes[++i];
|
||||
if (second === undefined || third === undefined) throw new Error('Invalid preset QR');
|
||||
result += String.fromCodePoint(((first & 0x0f) << 12) | ((second & 0x3f) << 6) | (third & 0x3f));
|
||||
continue;
|
||||
}
|
||||
if ((first & 0xf8) === 0xf0) {
|
||||
const second = bytes[++i];
|
||||
const third = bytes[++i];
|
||||
const fourth = bytes[++i];
|
||||
if (second === undefined || third === undefined || fourth === undefined) {
|
||||
throw new Error('Invalid preset QR');
|
||||
}
|
||||
result += String.fromCodePoint(
|
||||
((first & 0x07) << 18) | ((second & 0x3f) << 12) | ((third & 0x3f) << 6) | (fourth & 0x3f)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw new Error('Invalid preset QR');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
const bytes = stringToUtf8Bytes(value);
|
||||
let result = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const first = bytes[i];
|
||||
const second = bytes[i + 1];
|
||||
const third = bytes[i + 2];
|
||||
const triple = (first << 16) | ((second ?? 0) << 8) | (third ?? 0);
|
||||
result += BASE64_ALPHABET[(triple >> 18) & 0x3f];
|
||||
result += BASE64_ALPHABET[(triple >> 12) & 0x3f];
|
||||
result += second === undefined ? '=' : BASE64_ALPHABET[(triple >> 6) & 0x3f];
|
||||
result += third === undefined ? '=' : BASE64_ALPHABET[triple & 0x3f];
|
||||
}
|
||||
return result.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new Error('Invalid preset QR');
|
||||
}
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=');
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < padded.length; i += 4) {
|
||||
const a = BASE64_ALPHABET.indexOf(padded[i]);
|
||||
const b = BASE64_ALPHABET.indexOf(padded[i + 1]);
|
||||
const c = padded[i + 2] === '=' ? -1 : BASE64_ALPHABET.indexOf(padded[i + 2]);
|
||||
const d = padded[i + 3] === '=' ? -1 : BASE64_ALPHABET.indexOf(padded[i + 3]);
|
||||
if (a < 0 || b < 0 || (c < 0 && padded[i + 2] !== '=') || (d < 0 && padded[i + 3] !== '=')) {
|
||||
throw new Error('Invalid preset QR');
|
||||
}
|
||||
const triple = (a << 18) | (b << 12) | ((c < 0 ? 0 : c) << 6) | (d < 0 ? 0 : d);
|
||||
bytes.push((triple >> 16) & 0xff);
|
||||
if (c >= 0) bytes.push((triple >> 8) & 0xff);
|
||||
if (d >= 0) bytes.push(triple & 0xff);
|
||||
}
|
||||
return utf8BytesToString(bytes);
|
||||
}
|
||||
|
||||
export function sanitizeEQPresetFileName(name: string): string {
|
||||
const cleaned = name.replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
return cleaned || 'Astra EQ Preset';
|
||||
}
|
||||
|
||||
export function buildEQPresetFileName(name: string): string {
|
||||
return `${sanitizeEQPresetFileName(name)}.${EQ_PRESET_FILE_EXTENSION}`;
|
||||
}
|
||||
|
||||
export function stringifyEQPresetFileContents(preset: EQPreset): string {
|
||||
return `${JSON.stringify(serializeEQPresetData(preset), null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function parseEQPresetFileContents(contents: string, createId: () => string): EQPreset {
|
||||
try {
|
||||
return parseEQPresetData(JSON.parse(contents), createId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Unsupported preset version') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Invalid Astra EQ preset file');
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeEQPresetQr(preset: EQPreset): string {
|
||||
return `${EQ_PRESET_QR_PREFIX}${base64UrlEncode(JSON.stringify(serializeEQPresetData(preset)))}`;
|
||||
}
|
||||
|
||||
export function decodeEQPresetQr(value: string, createId: () => string): EQPreset {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith(EQ_PRESET_QR_PREFIX)) {
|
||||
throw new Error('Not an Astra EQ preset QR');
|
||||
}
|
||||
try {
|
||||
return parseEQPresetData(JSON.parse(base64UrlDecode(trimmed.slice(EQ_PRESET_QR_PREFIX.length))), createId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Not an Astra EQ preset QR') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Invalid Astra EQ preset QR');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { BottomSheetTextInput } from '@gorhom/bottom-sheet';
|
||||
import { Text } from '@/components/Text';
|
||||
import {
|
||||
fonts,
|
||||
radius,
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { EqSheet } from './EqSheet';
|
||||
|
||||
interface EQPresetNameSheetProps {
|
||||
defaultName: string;
|
||||
actionLabel: string;
|
||||
onSubmit: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EQPresetNameSheet({
|
||||
defaultName,
|
||||
actionLabel,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: EQPresetNameSheetProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const [name, setName] = useState(defaultName);
|
||||
const trimmed = name.trim();
|
||||
|
||||
const submit = () => {
|
||||
if (!trimmed) return;
|
||||
onClose();
|
||||
onSubmit(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
Name preset
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Preset name"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
maxLength={40}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary, !trimmed && styles.disabled]} disabled={!trimmed} onPress={submit}>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
input: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: fonts.sans.regular,
|
||||
fontSize: 16,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
cancel: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
}));
|
||||
|
||||
export default EQPresetNameSheet;
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Text } from '@/components/Text';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import type { EQPreset } from '@/types/audio';
|
||||
import { EqSheet } from './EqSheet';
|
||||
import { formatGain } from './format';
|
||||
|
||||
interface EQPresetPreviewSheetProps {
|
||||
preset: EQPreset;
|
||||
title?: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EQPresetPreviewSheet({
|
||||
preset,
|
||||
title = 'Import preset',
|
||||
confirmLabel = 'Import and Apply',
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: EQPresetPreviewSheetProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const enabledBands = preset.bands.filter((band) => band.enabled).length;
|
||||
const modeLabel = preset.mode === 'graphic' ? 'Graphic' : 'Parametric';
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.preview}>
|
||||
<Text variant="body" numberOfLines={1} color={colors.textPrimary}>
|
||||
{preset.name}
|
||||
</Text>
|
||||
<View style={styles.metaRow}>
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Mode
|
||||
</Text>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
{modeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Preamp
|
||||
</Text>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
{formatGain(preset.preamp)} dB
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaRow}>
|
||||
<Text variant="caption" color={colors.textTertiary}>
|
||||
Bands
|
||||
</Text>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
{enabledBands}/{preset.bands.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.cancel]} onPress={onClose}>
|
||||
<Text variant="label" color={colors.textSecondary}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.primary]}
|
||||
onPress={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
{confirmLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
preview: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.md,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.pill,
|
||||
},
|
||||
cancel: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
primary: {
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
}));
|
||||
|
||||
export default EQPresetPreviewSheet;
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import QRCode from 'react-native-qrcode-svg';
|
||||
import { Text } from '@/components/Text';
|
||||
import {
|
||||
radius,
|
||||
spacing,
|
||||
} from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import { EqSheet } from './EqSheet';
|
||||
|
||||
interface EQPresetQrSheetProps {
|
||||
presetName: string;
|
||||
value: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EQPresetQrSheet({ presetName, value, onClose }: EQPresetQrSheetProps) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
<EqSheet onClose={onClose}>
|
||||
<Text variant="heading" style={styles.title}>
|
||||
Preset QR
|
||||
</Text>
|
||||
<View style={styles.qrWrap}>
|
||||
<QRCode value={value} size={220} color="#000000" backgroundColor="#ffffff" quietZone={12} ecl="M" />
|
||||
</View>
|
||||
<Text variant="label" numberOfLines={1} color={colors.textSecondary} style={styles.name}>
|
||||
{presetName}
|
||||
</Text>
|
||||
<Pressable style={styles.done} onPress={onClose}>
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Done
|
||||
</Text>
|
||||
</Pressable>
|
||||
</EqSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
qrWrap: {
|
||||
alignSelf: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
},
|
||||
name: {
|
||||
alignSelf: 'center',
|
||||
maxWidth: 260,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
done: {
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accentGlow,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.accent,
|
||||
},
|
||||
}));
|
||||
|
||||
export default EQPresetQrSheet;
|
||||
@@ -379,7 +379,15 @@ export const useEQStore = create<EQStore>((set, get) => {
|
||||
},
|
||||
|
||||
importPreset: (preset) => {
|
||||
const stored: EQPreset = { ...preset, id: genEqId(), isCustom: true };
|
||||
const graphicGains = preset.mode === 'graphic' ? parseGraphicGains(preset.graphicGains) : null;
|
||||
const stored: EQPreset = {
|
||||
id: genEqId(),
|
||||
name: preset.name,
|
||||
preamp: clampPreamp(preset.preamp),
|
||||
bands: preset.bands.slice(0, EQ_MAX_BANDS).map((band) => createNormalizedEQBand(band, genEqId())),
|
||||
isCustom: true,
|
||||
...(graphicGains ? { mode: 'graphic' as const, graphicGains } : {}),
|
||||
};
|
||||
set({ presets: [...get().presets, stored] });
|
||||
get().applyPreset(stored.id);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user