sharable eq

This commit is contained in:
Boof2015
2026-07-07 15:52:12 -04:00
parent d05e45e7d3
commit ed6d587f5f
12 changed files with 1323 additions and 31 deletions
+198 -4
View File
@@ -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',
+200
View File
@@ -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,
},
}));