mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
astra signal initial commit
This commit is contained in:
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@boof2015/astra-signal": "^0.3.0",
|
||||
"@boof2015/xlrc": "^0.2.1",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
|
||||
@@ -1164,6 +1165,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@boof2015/astra-signal": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@boof2015/astra-signal/-/astra-signal-0.3.0.tgz",
|
||||
"integrity": "sha512-hPzi9Qv7VUK7DE+GWxciAIKm7QSgt5szdy+vwtavP0KvQEt8H8AqHO81xAgEPuR5d5EeaeEDXCF86PNXVJ9n5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@boof2015/xlrc": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@boof2015/xlrc/-/xlrc-0.2.1.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"main": "index.js",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@boof2015/astra-signal": "^0.3.0",
|
||||
"@boof2015/xlrc": "^0.2.1",
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/jetbrains-mono": "^0.4.1",
|
||||
@@ -72,6 +73,7 @@
|
||||
"test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts",
|
||||
"test:desktop-sync": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/importMatching.test.mts src/services/desktopSyncPlaylistMerge.test.mts src/services/desktopSyncPolicy.test.mts src/shared/sync/conflictPreview.test.mts",
|
||||
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
||||
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts",
|
||||
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
||||
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
||||
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,104 @@
|
||||
import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy';
|
||||
import { AlphaType, ColorType, Skia, rect, type SkImage } from '@shopify/react-native-skia';
|
||||
import {
|
||||
decodeSignalImage,
|
||||
type SignalDecodeResult,
|
||||
type SignalPayload,
|
||||
} from '@boof2015/astra-signal';
|
||||
import {
|
||||
signalGuideCaptureSourceRect,
|
||||
type SignalImageRect,
|
||||
type SignalImageSize,
|
||||
} from './signalScanGeometry';
|
||||
|
||||
const MAX_DIM = 2048;
|
||||
|
||||
export interface DecodeSignalFromUriOptions {
|
||||
/** Camera preview size used to map the on-screen guide into the full photo. */
|
||||
previewSize?: SignalImageSize;
|
||||
}
|
||||
|
||||
function fullRect(source: SkImage): SignalImageRect {
|
||||
return { x: 0, y: 0, width: source.width(), height: source.height() };
|
||||
}
|
||||
|
||||
function decodeRect(source: SkImage, crop: SignalImageRect): SignalDecodeResult {
|
||||
const scale = Math.min(1, MAX_DIM / Math.max(crop.width, crop.height));
|
||||
const width = Math.max(1, Math.round(crop.width * scale));
|
||||
const height = Math.max(1, Math.round(crop.height * scale));
|
||||
const surface = Skia.Surface.MakeOffscreen(width, height);
|
||||
if (!surface) throw new Error('Could not process that image.');
|
||||
const paint = Skia.Paint();
|
||||
const canvas = surface.getCanvas();
|
||||
canvas.drawImageRect(
|
||||
source,
|
||||
rect(crop.x, crop.y, crop.width, crop.height),
|
||||
rect(0, 0, width, height),
|
||||
paint
|
||||
);
|
||||
surface.flush();
|
||||
const snapshot = surface.makeImageSnapshot();
|
||||
const pixels = snapshot.readPixels(0, 0, {
|
||||
width,
|
||||
height,
|
||||
colorType: ColorType.RGBA_8888,
|
||||
alphaType: AlphaType.Unpremul,
|
||||
});
|
||||
paint.dispose();
|
||||
snapshot.dispose();
|
||||
surface.dispose();
|
||||
if (!pixels) throw new Error('Could not read image pixels.');
|
||||
return decodeSignalImage({ data: pixels, width, height });
|
||||
}
|
||||
|
||||
function logDiagnostics(result: SignalDecodeResult, source: 'guide' | 'full'): void {
|
||||
if (!__DEV__) return;
|
||||
console.debug('[Astra Signal] decoded image', {
|
||||
source,
|
||||
tier: result.tier,
|
||||
correctedBytes: result.correctedBytes,
|
||||
erasedBytes: result.erasedBytes,
|
||||
confidence: result.confidence,
|
||||
});
|
||||
}
|
||||
|
||||
export async function decodeSignalFromUri(
|
||||
uri: string,
|
||||
options: DecodeSignalFromUriOptions = {}
|
||||
): Promise<SignalPayload> {
|
||||
const b64 = await readAsStringAsync(uri, { encoding: EncodingType.Base64 });
|
||||
const encoded = Skia.Data.fromBase64(b64);
|
||||
const source = Skia.Image.MakeImageFromEncoded(encoded);
|
||||
encoded.dispose();
|
||||
if (!source) throw new Error('Could not read that image.');
|
||||
|
||||
try {
|
||||
const crop = options.previewSize
|
||||
? signalGuideCaptureSourceRect(
|
||||
{ width: source.width(), height: source.height() },
|
||||
options.previewSize
|
||||
)
|
||||
: null;
|
||||
if (crop) {
|
||||
try {
|
||||
const result = decodeRect(source, crop);
|
||||
logDiagnostics(result, 'guide');
|
||||
return result.payload;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.debug('[Astra Signal] guide decode failed', error);
|
||||
// The guide crop is a fast, high-resolution first attempt. Full-image
|
||||
// search below handles framing mismatch and images picked from storage.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = decodeRect(source, fullRect(source));
|
||||
logDiagnostics(result, 'full');
|
||||
return result.payload;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.debug('[Astra Signal] full-image decode failed', error);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
source.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
SIGNAL_SCAN_GUIDE,
|
||||
signalGuideCapturePreviewRect,
|
||||
signalGuideCaptureSourceRect,
|
||||
} from './signalScanGeometry.ts';
|
||||
|
||||
test('uses the padded small-tier presentation geometry for the visible guide', () => {
|
||||
assert.equal(SIGNAL_SCAN_GUIDE.aspectRatio, 2.65);
|
||||
});
|
||||
|
||||
test('the high-resolution crop spans the preview but keeps allocation height bounded', () => {
|
||||
const preview = { width: 360, height: 640 };
|
||||
const crop = signalGuideCapturePreviewRect(preview);
|
||||
assert.ok(crop);
|
||||
const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2);
|
||||
const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio;
|
||||
assert.equal(crop.x, 0);
|
||||
assert.equal(crop.width, preview.width);
|
||||
assert.ok(crop.width > guideWidth);
|
||||
assert.ok(crop.height > guideHeight);
|
||||
assert.ok(crop.height < guideHeight * 1.5);
|
||||
});
|
||||
|
||||
test('maps the expanded preview band into a portrait camera capture', () => {
|
||||
const crop = signalGuideCaptureSourceRect(
|
||||
{ width: 3000, height: 4000 },
|
||||
{ width: 360, height: 640 }
|
||||
);
|
||||
assert.ok(crop);
|
||||
assert.ok(crop.x > 0);
|
||||
assert.ok(crop.x + crop.width < 3000);
|
||||
assert.ok(crop.width > 2200);
|
||||
assert.ok(crop.height > 950);
|
||||
assert.ok(crop.height < 1200);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface SignalImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SignalImageRect extends SignalImageSize {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// The visible frame describes the common small tier plus the real isolation
|
||||
// padding used by the mobile presentation, rather than only the black envelope.
|
||||
export const SIGNAL_SCAN_GUIDE = {
|
||||
horizontalInset: 0.06,
|
||||
top: 0.32,
|
||||
aspectRatio: 2.65,
|
||||
} as const;
|
||||
|
||||
const OPTICAL_SEARCH_ASPECT = 228 / 40;
|
||||
|
||||
/**
|
||||
* The visible guide is not a crop boundary. Capture the entire preview width
|
||||
* while keeping the vertical band bounded. A large first-pass bitmap can leave
|
||||
* too much allocation pressure for the full-image fallback on a phone.
|
||||
*/
|
||||
export function signalGuideCapturePreviewRect(preview: SignalImageSize): SignalImageRect | null {
|
||||
if (preview.width <= 0 || preview.height <= 0) return null;
|
||||
const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2);
|
||||
const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio;
|
||||
const guideCenterY = preview.height * SIGNAL_SCAN_GUIDE.top + guideHeight / 2;
|
||||
const searchHeight = (guideWidth / OPTICAL_SEARCH_ASPECT) * 3;
|
||||
const y0 = Math.max(0, guideCenterY - searchHeight / 2);
|
||||
const y1 = Math.min(preview.height, guideCenterY + searchHeight / 2);
|
||||
return { x: 0, y: y0, width: preview.width, height: y1 - y0 };
|
||||
}
|
||||
|
||||
/** Map the expanded visible-preview search band into the captured photo. */
|
||||
export function signalGuideCaptureSourceRect(
|
||||
source: SignalImageSize,
|
||||
preview: SignalImageSize
|
||||
): SignalImageRect | null {
|
||||
const capture = signalGuideCapturePreviewRect(preview);
|
||||
if (!capture || source.width <= 0 || source.height <= 0) return null;
|
||||
const coverScale = Math.max(preview.width / source.width, preview.height / source.height);
|
||||
const overflowX = (source.width * coverScale - preview.width) / 2;
|
||||
const overflowY = (source.height * coverScale - preview.height) / 2;
|
||||
const x0 = Math.max(0, (capture.x + overflowX) / coverScale);
|
||||
const y0 = Math.max(0, (capture.y + overflowY) / coverScale);
|
||||
const x1 = Math.min(source.width, (capture.x + capture.width + overflowX) / coverScale);
|
||||
const y1 = Math.min(source.height, (capture.y + capture.height + overflowY) / coverScale);
|
||||
if (x1 - x0 < 2 || y1 - y0 < 2) return null;
|
||||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Track } from '../types/audio.ts';
|
||||
import {
|
||||
SIGNAL_LINK_PREFIX,
|
||||
decodeTrackSignalLink,
|
||||
encodeTrackSignalLink,
|
||||
signalInputFromTrack,
|
||||
signalLayoutFromTrack,
|
||||
} from './signalShare.ts';
|
||||
|
||||
function track(overrides: Partial<Track> = {}): Track {
|
||||
return {
|
||||
id: 'signal-test',
|
||||
path: '/music/replay.flac',
|
||||
title: 'Replay',
|
||||
artist: 'ナナツカゼ',
|
||||
album: 'Signal Tests',
|
||||
duration: 213.6,
|
||||
format: 'flac',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('converts a Track into rounded, database-free Signal metadata', () => {
|
||||
assert.deepEqual(signalInputFromTrack(track()), {
|
||||
artist: 'ナナツカゼ',
|
||||
title: 'Replay',
|
||||
durationSec: 214,
|
||||
});
|
||||
assert.equal(signalInputFromTrack(track({ duration: Number.NaN })).durationSec, 0);
|
||||
});
|
||||
|
||||
test('creates a v3 connected layout and round-trips a Unicode link', () => {
|
||||
const source = track();
|
||||
const layout = signalLayoutFromTrack(source);
|
||||
assert.equal(layout.version, 3);
|
||||
assert.equal(layout.columns.length, layout.dataColumns + 8);
|
||||
|
||||
const link = encodeTrackSignalLink(source);
|
||||
assert.ok(link.startsWith(SIGNAL_LINK_PREFIX));
|
||||
assert.deepEqual(decodeTrackSignalLink(link), {
|
||||
version: 3,
|
||||
type: 'metadata',
|
||||
artist: 'ナナツカゼ',
|
||||
title: 'Replay',
|
||||
durationSec: 214,
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves ASCII case and punctuation in track metadata', () => {
|
||||
const source = track({ artist: 'N!GHT', title: '#iwannadance', duration: 222 });
|
||||
const layout = signalLayoutFromTrack(source);
|
||||
assert.equal(layout.payload.artist, 'N!GHT');
|
||||
assert.equal(layout.payload.title, '#iwannadance');
|
||||
assert.deepEqual(decodeTrackSignalLink(encodeTrackSignalLink(source)), {
|
||||
version: 3,
|
||||
type: 'metadata',
|
||||
artist: 'N!GHT',
|
||||
title: '#iwannadance',
|
||||
durationSec: 222,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-v3, malformed, and CRC-corrupted links', () => {
|
||||
assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/);
|
||||
assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`));
|
||||
const valid = encodeTrackSignalLink(track());
|
||||
const index = SIGNAL_LINK_PREFIX.length + 5;
|
||||
const replacement = valid[index] === 'A' ? 'B' : 'A';
|
||||
const corrupted = `${valid.slice(0, index)}${replacement}${valid.slice(index + 1)}`;
|
||||
assert.throws(() => decodeTrackSignalLink(corrupted));
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
SIGNAL_LINK_PREFIX,
|
||||
decodeSignalLink as decodeLibrarySignalLink,
|
||||
encodeSignal,
|
||||
encodeSignalLink as encodeLibrarySignalLink,
|
||||
type SignalInput,
|
||||
type SignalLayout,
|
||||
type SignalPayload,
|
||||
} from '@boof2015/astra-signal';
|
||||
import type { Track } from '../types/audio';
|
||||
|
||||
export { SIGNAL_LINK_PREFIX };
|
||||
|
||||
/** Convert local track metadata into the database-free v3 Signal input. */
|
||||
export function signalInputFromTrack(track: Track): SignalInput {
|
||||
return {
|
||||
artist: track.artist,
|
||||
title: track.title,
|
||||
durationSec: Number.isFinite(track.duration) ? Math.round(track.duration) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the ECC-protected optical layout rendered by SignalCode. */
|
||||
export function signalLayoutFromTrack(track: Track): SignalLayout {
|
||||
return encodeSignal(signalInputFromTrack(track));
|
||||
}
|
||||
|
||||
/** Build the compact v3 link frame. Links carry CRC, but no visual padding or ECC. */
|
||||
export function encodeTrackSignalLink(track: Track): string {
|
||||
return encodeLibrarySignalLink(signalInputFromTrack(track));
|
||||
}
|
||||
|
||||
export function decodeTrackSignalLink(value: string): SignalPayload {
|
||||
return decodeLibrarySignalLink(value);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Track } from '../types/audio.ts';
|
||||
import { SIGNAL_LINK_PREFIX, encodeTrackSignalLink } from './signalShare.ts';
|
||||
import { getSignalShareRedirectPath } from './signalShareIntent.ts';
|
||||
|
||||
const source: Track = {
|
||||
id: 'intent-test',
|
||||
path: '/music/replay.flac',
|
||||
title: 'Replay',
|
||||
artist: 'ナナツカゼ',
|
||||
album: 'Signal Tests',
|
||||
duration: 214,
|
||||
format: 'flac',
|
||||
};
|
||||
|
||||
test('routes native and percent-encoded Astra Signal v3 links', () => {
|
||||
const link = encodeTrackSignalLink(source);
|
||||
const payload = link.slice(SIGNAL_LINK_PREFIX.length);
|
||||
assert.equal(getSignalShareRedirectPath(link), `/signal/import?data=${payload}`);
|
||||
assert.equal(getSignalShareRedirectPath(encodeURIComponent(link)), `/signal/import?data=${payload}`);
|
||||
assert.equal(getSignalShareRedirectPath(`astra://signal:v3:${payload}`), `/signal/import?data=${payload}`);
|
||||
});
|
||||
|
||||
test('ignores unrelated, v2, and empty Signal intents', () => {
|
||||
assert.equal(getSignalShareRedirectPath('https://example.com'), null);
|
||||
assert.equal(getSignalShareRedirectPath('astra:signal:v2:AAAA'), null);
|
||||
assert.equal(getSignalShareRedirectPath('astra:signal:v3:'), null);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SIGNAL_LINK_PREFIX } from './signalShare.ts';
|
||||
|
||||
// Match the scheme-less marker because Android/Expo can normalize opaque
|
||||
// `astra:` URIs before redirectSystemPath receives them.
|
||||
const SIGNAL_MARKER = SIGNAL_LINK_PREFIX.replace(/^astra:/, '');
|
||||
|
||||
/** Return the import route for an Astra Signal v3 deep link, or null. */
|
||||
export function getSignalShareRedirectPath(path: string): string | null {
|
||||
let candidate = path.trim();
|
||||
try {
|
||||
candidate = decodeURIComponent(candidate);
|
||||
} catch {
|
||||
// Malformed percent-encoding cannot be a valid frame, but matching the raw
|
||||
// string keeps this router helper side-effect free.
|
||||
}
|
||||
const idx = candidate.indexOf(SIGNAL_MARKER);
|
||||
if (idx === -1) return null;
|
||||
const payload = candidate.slice(idx + SIGNAL_MARKER.length).match(/^[A-Za-z0-9_-]+/)?.[0];
|
||||
if (!payload) return null;
|
||||
return `/signal/import?data=${payload}`;
|
||||
}
|
||||
@@ -363,6 +363,18 @@ export function NowPlayingOverlay() {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!isDesktopTarget) {
|
||||
menuItems.push({
|
||||
key: 'share-signal',
|
||||
label: 'Share as Signal',
|
||||
icon: 'pulse-outline',
|
||||
onPress: () => {
|
||||
closeMenu();
|
||||
dismissSheet();
|
||||
router.navigate('/signal' as never);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The overlay stays mounted; open/close is this one shared value sliding the
|
||||
// sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import { AlphaType, Canvas, ColorType, Path, Rect, Skia } from '@shopify/react-native-skia';
|
||||
import {
|
||||
SIGNAL_SPEC,
|
||||
levelHeightModules,
|
||||
rasterizeSignal,
|
||||
type SignalLayout,
|
||||
} from '@boof2015/astra-signal';
|
||||
|
||||
export interface SignalCodeHandle {
|
||||
/** Canonical six-pixels-per-module PNG as base64, independent of display size. */
|
||||
snapshot: () => string | null;
|
||||
}
|
||||
|
||||
interface SignalCodeProps {
|
||||
layout: SignalLayout;
|
||||
width: number;
|
||||
foreground: string;
|
||||
background: string;
|
||||
exportForeground?: string;
|
||||
exportBackground?: string;
|
||||
}
|
||||
|
||||
const G = SIGNAL_SPEC.geom;
|
||||
|
||||
function rgb(hex: string): [number, number, number] {
|
||||
const value = hex.replace(/^#/, '');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(value)) throw new Error('Signal colors must be six-digit hex values');
|
||||
return [
|
||||
Number.parseInt(value.slice(0, 2), 16),
|
||||
Number.parseInt(value.slice(2, 4), 16),
|
||||
Number.parseInt(value.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
/** Render the v3 connected upper/lower spectrum envelope directly from SignalLayout. */
|
||||
export const SignalCode = forwardRef<SignalCodeHandle, SignalCodeProps>(function SignalCode(
|
||||
{ layout, width, foreground, background, exportForeground = foreground, exportBackground = background },
|
||||
ref
|
||||
) {
|
||||
const scale = width / layout.widthModules;
|
||||
const height = layout.heightModules * scale;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
snapshot: () => {
|
||||
const raster = rasterizeSignal(layout, {
|
||||
scale: 6,
|
||||
foreground: rgb(exportForeground),
|
||||
background: rgb(exportBackground),
|
||||
});
|
||||
const bytes = new Uint8Array(raster.data.length);
|
||||
bytes.set(raster.data);
|
||||
const data = Skia.Data.fromBytes(bytes);
|
||||
const image = Skia.Image.MakeImage(
|
||||
{
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
colorType: ColorType.RGBA_8888,
|
||||
alphaType: AlphaType.Unpremul,
|
||||
},
|
||||
data,
|
||||
raster.width * 4
|
||||
);
|
||||
data.dispose();
|
||||
if (!image) return null;
|
||||
try {
|
||||
return image.encodeToBase64();
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[exportBackground, exportForeground, layout]
|
||||
);
|
||||
|
||||
const envelope = useMemo(() => {
|
||||
const path = Skia.Path.Make();
|
||||
const centerY = (G.quietModules + G.halfHeightModules) * scale;
|
||||
for (let index = 0; index < layout.columns.length; index += 1) {
|
||||
const column = layout.columns[index];
|
||||
if (!column) continue;
|
||||
const upper = levelHeightModules(column.upperLevel) * scale;
|
||||
const lower = levelHeightModules(column.lowerLevel) * scale;
|
||||
const x = (G.quietModules + index * G.columnPitchModules) * scale;
|
||||
path.addRect(Skia.XYWHRect(x, centerY - upper, G.columnPitchModules * scale, upper + lower));
|
||||
}
|
||||
return path;
|
||||
}, [layout, scale]);
|
||||
|
||||
return (
|
||||
<Canvas style={{ width, height }}>
|
||||
<Rect x={0} y={0} width={width} height={height} color={background} />
|
||||
<Path path={envelope} color={foreground} />
|
||||
</Canvas>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Text } from '@/components/Text';
|
||||
import { formatDuration } from '@/lib/format';
|
||||
import { radius, spacing } from '@/theme';
|
||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||
import type { SignalPayload } from '@boof2015/astra-signal';
|
||||
|
||||
/**
|
||||
* Shows the song a scanned/imported Signal decoded to. Resolving this to a
|
||||
* playable track (local library match, then online lookup) is the next phase;
|
||||
* for now it confirms the round-trip — the make-or-break for the format.
|
||||
*/
|
||||
export function SignalResultCard({ payload }: { payload: SignalPayload }) {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const title = payload.title.trim();
|
||||
const artist = payload.artist.trim();
|
||||
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.badge}>
|
||||
<Ionicons name="pulse" size={18} color={colors.accentTextStrong} />
|
||||
<Text variant="label" color={colors.accentTextStrong}>
|
||||
Signal found
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="title" style={styles.title}>
|
||||
{title || 'Unknown title'}
|
||||
</Text>
|
||||
<Text variant="body" color={colors.textSecondary}>
|
||||
{artist || 'Unknown artist'}
|
||||
</Text>
|
||||
{payload.durationSec > 0 ? (
|
||||
<View style={styles.metaRow}>
|
||||
<Ionicons name="time-outline" size={15} color={colors.textTertiary} />
|
||||
<Text variant="mono" color={colors.textTertiary}>
|
||||
{formatDuration(payload.durationSec)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const useStyles = createThemedStyles((colors) => ({
|
||||
card: {
|
||||
borderRadius: radius.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.glassBorder,
|
||||
backgroundColor: colors.glassBg,
|
||||
padding: spacing.lg,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.accentGlow,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user