mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
astra signal initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user