astra signal initial commit

This commit is contained in:
Boof2015
2026-07-15 00:55:44 -04:00
parent 36fadf9925
commit ae3a49cf68
16 changed files with 1101 additions and 0 deletions
+4
View File
@@ -3,6 +3,7 @@ import {
resolveNotificationClick,
} from '@/audio/notificationIntent';
import { getEQPresetShareRedirectPath } from '@/audio/eqShareIntent';
import { getSignalShareRedirectPath } from '@/audio/signalShareIntent';
type RedirectSystemPathEvent = {
path: string;
@@ -15,5 +16,8 @@ export async function redirectSystemPath({ path }: RedirectSystemPathEvent): Pro
const eqShareRedirect = getEQPresetShareRedirectPath(path);
if (eqShareRedirect) return eqShareRedirect;
const signalRedirect = getSignalShareRedirectPath(path);
if (signalRedirect) return signalRedirect;
return path;
}
+86
View File
@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
import { decodeTrackSignalLink, SIGNAL_LINK_PREFIX } from '@/audio/signalShare';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { SignalPayload } from '@boof2015/astra-signal';
export default function SignalImportScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const { data } = useLocalSearchParams<{ data?: string }>();
const payload = useMemo<SignalPayload | null>(() => {
if (!data) return null;
try {
return decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}${data}`);
} catch {
return null;
}
}, [data]);
const goToSignal = () => router.replace('/signal' as never);
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={goToSignal} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Signal
</Text>
</Pressable>
</View>
{payload ? (
<SignalResultCard payload={payload} />
) : (
<View style={styles.errorCard}>
<Ionicons name="alert-circle-outline" size={28} color={colors.warning} />
<Text variant="body">This link does not contain a valid Astra Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={goToSignal}>
<Text variant="body" color={colors.accentTextStrong}>
Go to Signal
</Text>
</Pressable>
</View>
)}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
errorCard: {
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',
},
}));
+181
View File
@@ -0,0 +1,181 @@
import { useMemo, useRef } from 'react';
import { Pressable, Share, StyleSheet, useWindowDimensions, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalCode, type SignalCodeHandle } from '@/components/signal/SignalCode';
import { encodeTrackSignalLink, signalLayoutFromTrack } from '@/audio/signalShare';
import { usePlayerStore } from '@/stores/playerStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
// Keep the device presentation identical to the canonical shared PNG. The
// near-white isolation field is part of the proven phone-to-phone geometry.
const CODE_FG = '#0b0b12';
const CODE_BG = '#f4f4f6';
export default function SignalScreen() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const router = useRouter();
const track = usePlayerStore((s) => s.currentTrack);
const codeRef = useRef<SignalCodeHandle>(null);
const { width: screenWidth } = useWindowDimensions();
const layout = useMemo(() => (track ? signalLayoutFromTrack(track) : null), [track]);
const availableWidth = Math.max(1, screenWidth - spacing.lg * 4);
const targetWidth = layout?.tier === 'small' ? 280 : layout?.tier === 'medium' ? 340 : availableWidth;
const codeWidth = Math.min(targetWidth, availableWidth);
const shareImage = async () => {
const base64 = codeRef.current?.snapshot();
if (!base64 || !cacheDirectory) return;
const fileUri = `${cacheDirectory}astra-signal.png`;
await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 });
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'image/png',
dialogTitle: 'Share Astra Signal',
UTI: 'public.png',
});
}
};
const shareLink = async () => {
if (!track) return;
await Share.share({ message: encodeTrackSignalLink(track) });
};
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Back
</Text>
</Pressable>
<Pressable android_ripple={ripple.icon(22)} style={styles.scanBtn} onPress={() => router.navigate('/signal/scan' as never)} hitSlop={8}>
<Ionicons name="scan-outline" size={22} color={colors.accent} />
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Astra Signal
</Text>
{!track || !layout ? (
<View style={styles.empty}>
<Ionicons name="pulse-outline" size={30} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary}>
Play a song to make its Signal.
</Text>
</View>
) : (
<View style={styles.body}>
<View style={styles.codeCard}>
<SignalCode
ref={codeRef}
layout={layout}
width={codeWidth}
foreground={CODE_FG}
background={CODE_BG}
exportForeground={CODE_FG}
exportBackground={CODE_BG}
/>
</View>
<Text variant="heading" style={styles.trackTitle} numberOfLines={1}>
{track.title}
</Text>
<Text variant="body" color={colors.textSecondary} numberOfLines={1}>
{track.artist}
</Text>
<View style={styles.actions}>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void shareImage()}>
<Ionicons name="share-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Share Signal
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => void shareLink()}>
<Ionicons name="link-outline" size={18} color={colors.textPrimary} />
<Text variant="body">Share link</Text>
</Pressable>
</View>
</View>
)}
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
scanBtn: {
padding: spacing.xs,
},
heading: {
marginBottom: spacing.lg,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
},
body: {
alignItems: 'center',
},
codeCard: {
padding: spacing.lg,
borderRadius: radius.lg,
backgroundColor: CODE_BG,
marginBottom: spacing.lg,
overflow: 'hidden',
},
trackTitle: {
marginTop: spacing.xs,
},
actions: {
flexDirection: 'row',
gap: spacing.md,
marginTop: spacing.xl,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
secondaryButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
borderRadius: radius.sm,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.lg,
justifyContent: 'center',
},
}));
+284
View File
@@ -0,0 +1,284 @@
import { useRef, useState } from 'react';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as DocumentPicker from 'expo-document-picker';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { SignalPayload } from '@boof2015/astra-signal';
export default function SignalScanScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const cameraRef = useRef<CameraView>(null);
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<SignalPayload | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 });
const runDecode = async (uri: string, useGuideCrop = false) => {
setBusy(true);
setError(null);
try {
setResult(await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined));
} catch {
setError("Couldn't read Signal. Line the code up in the frame, hold steady, and try again.");
} finally {
setBusy(false);
}
};
const capture = async () => {
if (busy) return;
const photo = await cameraRef.current?.takePictureAsync({ quality: 1 });
if (photo?.uri) await runDecode(photo.uri, true);
};
const pickImage = async () => {
if (busy) return;
const picked = await DocumentPicker.getDocumentAsync({ type: 'image/*', copyToCacheDirectory: true });
const uri = picked.assets?.[0]?.uri;
if (uri) await runDecode(uri);
};
return (
<Screen>
<View style={styles.header}>
<Pressable android_ripple={ripple.bounded} style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Signal
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
Scan the Signal
</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 a Signal.</Text>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => void requestPermission()}>
<Text variant="body" color={colors.accentTextStrong}>
Allow camera
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.linkButton} onPress={() => void pickImage()}>
<Text variant="body" color={colors.accent}>
Or pick a Signal image
</Text>
</Pressable>
</View>
) : (
<View style={styles.scannerFrame}>
<CameraView
ref={cameraRef}
style={styles.camera}
facing="back"
onLayout={(event) => setPreviewSize(event.nativeEvent.layout)}
/>
<View pointerEvents="none" style={styles.scanGuide}>
<View style={[styles.guideCorner, styles.guideTopLeft]} />
<View style={[styles.guideCorner, styles.guideTopRight]} />
<View style={[styles.guideCorner, styles.guideBottomLeft]} />
<View style={[styles.guideCorner, styles.guideBottomRight]} />
</View>
{busy ? (
<View style={styles.busyOverlay}>
<ActivityIndicator color={colors.accent} />
<Text variant="label" color={colors.textPrimary}>
Reading signal
</Text>
</View>
) : null}
<View style={styles.controls}>
<Pressable android_ripple={ripple.icon(28)} style={styles.iconButton} onPress={() => void pickImage()} hitSlop={8}>
<Ionicons name="image-outline" size={24} color={colors.textPrimary} />
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.shutter} onPress={() => void capture()} hitSlop={8}>
<Ionicons name="pulse" size={26} color={colors.accentTextStrong} />
</Pressable>
<View style={styles.iconButton} />
</View>
</View>
)}
{error ? (
<View style={styles.errorPanel}>
<Ionicons name="alert-circle-outline" size={20} color={colors.warning} />
<Text variant="body" color={colors.textPrimary} style={styles.errorCopy}>
{error}
</Text>
</View>
) : null}
{result ? (
<View style={styles.resultWrap}>
<SignalResultCard payload={result} />
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={() => setResult(null)}>
<Text variant="body" color={colors.accentTextStrong}>
Scan another
</Text>
</Pressable>
</View>
) : 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',
},
linkButton: {
alignItems: 'center',
paddingVertical: spacing.sm,
},
scannerFrame: {
flex: 1,
borderRadius: radius.md,
overflow: 'hidden',
backgroundColor: colors.bgSecondary,
marginBottom: spacing.lg,
},
camera: {
flex: 1,
},
scanGuide: {
position: 'absolute',
left: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`,
right: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`,
top: `${SIGNAL_SCAN_GUIDE.top * 100}%`,
aspectRatio: SIGNAL_SCAN_GUIDE.aspectRatio,
},
guideCorner: {
position: 'absolute',
width: 30,
height: 22,
borderColor: colors.accent,
},
guideTopLeft: {
left: 0,
top: 0,
borderLeftWidth: 3,
borderTopWidth: 3,
borderTopLeftRadius: radius.sm,
},
guideTopRight: {
right: 0,
top: 0,
borderRightWidth: 3,
borderTopWidth: 3,
borderTopRightRadius: radius.sm,
},
guideBottomLeft: {
left: 0,
bottom: 0,
borderLeftWidth: 3,
borderBottomWidth: 3,
borderBottomLeftRadius: radius.sm,
},
guideBottomRight: {
right: 0,
bottom: 0,
borderRightWidth: 3,
borderBottomWidth: 3,
borderBottomRightRadius: radius.sm,
},
busyOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
backgroundColor: 'rgba(0,0,0,0.35)',
},
controls: {
position: 'absolute',
left: spacing.lg,
right: spacing.lg,
bottom: spacing.lg,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
iconButton: {
width: 48,
height: 48,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgSecondary,
},
shutter: {
width: 64,
height: 64,
borderRadius: radius.pill,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accent,
},
errorPanel: {
flexDirection: 'row',
gap: spacing.sm,
alignItems: 'center',
borderRadius: radius.md,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
padding: spacing.md,
marginBottom: spacing.md,
},
errorCopy: {
flex: 1,
},
resultWrap: {
gap: spacing.md,
marginBottom: spacing.lg,
},
}));