diff --git a/package-lock.json b/package-lock.json
index 642dac1..1e10d9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
- "@boof2015/astra-signal": "^0.3.0",
+ "@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",
diff --git a/package.json b/package.json
index c69d9ec..8973fc7 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"main": "index.js",
"version": "0.1.0",
"dependencies": {
- "@boof2015/astra-signal": "^0.3.0",
+ "@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",
diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx
index b781da8..8970656 100644
--- a/src/app/(tabs)/index.tsx
+++ b/src/app/(tabs)/index.tsx
@@ -124,9 +124,11 @@ function formatHomeClockDate(date: Date): string {
function HomeMasthead({
mode,
onSearch,
+ onScan,
}: {
mode: HomeGreetingTextMode;
onSearch: () => void;
+ onScan: () => void;
}) {
const styles = useStyles();
const colors = useColors();
@@ -189,8 +191,29 @@ function HomeMasthead({
);
+ const scanButton = (
+
+
+
+ );
+
+ const utilityButtons = (
+
+ {scanButton}
+ {searchButton}
+
+ );
+
if (mode === 'off') {
- return {searchButton};
+ return {utilityButtons};
}
const primary = mode === 'clock' ? formatHomeClockTime(clockNow) : greeting.primary;
@@ -208,7 +231,7 @@ function HomeMasthead({
) : null}
- {searchButton}
+ {utilityButtons}
);
}
@@ -600,6 +623,7 @@ export default function HomeScreen() {
};
const openSearch = () => openQuickSearch();
+ const openSignalScanner = () => router.push('/signal/scan' as never);
return (
@@ -611,7 +635,11 @@ export default function HomeScreen() {
onScroll={scrollTop.onScroll}
scrollEventThrottle={scrollTop.scrollEventThrottle}
>
-
+
@@ -741,6 +769,11 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'center',
gap: spacing.xs,
},
+ mastheadActions: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm,
+ },
mastheadPrimary: {
fontSize: 28,
lineHeight: 32,
diff --git a/src/app/signal/index.tsx b/src/app/signal/index.tsx
index 9a037b2..d1ddf11 100644
--- a/src/app/signal/index.tsx
+++ b/src/app/signal/index.tsx
@@ -6,8 +6,10 @@ import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-syst
import * as Sharing from 'expo-sharing';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
+import { NowPlayingWash } from '@/components/NowPlayingWash';
import { SignalCode, type SignalCodeHandle } from '@/components/signal/SignalCode';
import { encodeTrackSignalLink, signalLayoutFromTrack } from '@/audio/signalShare';
+import { playerBackdropArtworkSource } from '@/library/artwork';
import { usePlayerStore } from '@/stores/playerStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
@@ -27,6 +29,7 @@ export default function SignalScreen() {
const codeRef = useRef(null);
const { width: screenWidth } = useWindowDimensions();
const layout = useMemo(() => (track ? signalLayoutFromTrack(track) : null), [track]);
+ const artworkWash = playerBackdropArtworkSource(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);
@@ -52,6 +55,10 @@ export default function SignalScreen() {
return (
+
router.back()} hitSlop={8}>
@@ -59,9 +66,6 @@ export default function SignalScreen() {
Back
- router.navigate('/signal/scan' as never)} hitSlop={8}>
-
-
@@ -77,6 +81,18 @@ export default function SignalScreen() {
) : (
+
+
+ NOW PLAYING
+
+
+ {track.title}
+
+
+ {track.artist}
+
+
+
-
- {track.title}
-
-
- {track.artist}
+
+
+ Keep the full white card visible when another phone scans it.
void shareImage()}>
- Share Signal
+ Share image
void shareLink()}>
@@ -126,9 +140,6 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center',
gap: 2,
},
- scanBtn: {
- padding: spacing.xs,
- },
heading: {
marginBottom: spacing.lg,
},
@@ -141,18 +152,33 @@ const useStyles = createThemedStyles((colors) => ({
body: {
alignItems: 'center',
},
+ trackMeta: {
+ width: '100%',
+ alignItems: 'center',
+ gap: spacing.xs,
+ marginBottom: spacing.lg,
+ },
codeCard: {
padding: spacing.lg,
borderRadius: radius.lg,
backgroundColor: CODE_BG,
marginBottom: spacing.lg,
overflow: 'hidden',
+ elevation: 6,
+ shadowColor: '#000000',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.22,
+ shadowRadius: 14,
},
trackTitle: {
- marginTop: spacing.xs,
+ textAlign: 'center',
+ },
+ hint: {
+ maxWidth: 320,
+ textAlign: 'center',
},
actions: {
- flexDirection: 'row',
+ width: '100%',
gap: spacing.md,
marginTop: spacing.xl,
},
@@ -160,7 +186,7 @@ const useStyles = createThemedStyles((colors) => ({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
- minHeight: 44,
+ minHeight: 48,
borderRadius: radius.sm,
backgroundColor: colors.accent,
paddingHorizontal: spacing.lg,
@@ -170,7 +196,7 @@ const useStyles = createThemedStyles((colors) => ({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
- minHeight: 44,
+ minHeight: 48,
borderRadius: radius.sm,
backgroundColor: colors.bgSecondary,
borderWidth: StyleSheet.hairlineWidth,
diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx
index dab36da..d151cbd 100644
--- a/src/app/signal/scan.tsx
+++ b/src/app/signal/scan.tsx
@@ -1,5 +1,5 @@
-import { useRef, useState } from 'react';
-import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
+import { useEffect, useRef, useState } from 'react';
+import { 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';
@@ -7,32 +7,64 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
+import {
+ SignalScanTransition,
+ type SignalScanPhase,
+} from '@/components/signal/SignalScanTransition';
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
+import { usePlayerStore } from '@/stores/playerStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import type { SignalPayload } from '@boof2015/astra-signal';
+const MIN_READING_MS = 600;
+const FAILURE_RETURN_MS = 480;
+
export default function SignalScanScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
+ const currentTrack = usePlayerStore((state) => state.currentTrack);
const cameraRef = useRef(null);
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
+ const [phase, setPhase] = useState('idle');
const [error, setError] = useState(null);
const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 });
+ const readingStartedAt = useRef(0);
- const runDecode = async (uri: string, useGuideCrop = false) => {
+ useEffect(() => {
+ if (phase !== 'failure') return;
+ const timeout = setTimeout(() => setPhase('idle'), FAILURE_RETURN_MS);
+ return () => clearTimeout(timeout);
+ }, [phase]);
+
+ const beginReading = () => {
+ readingStartedAt.current = Date.now();
setBusy(true);
setError(null);
+ setPhase('reading');
+ };
+
+ const finishReadingBeat = async () => {
+ const remaining = MIN_READING_MS - (Date.now() - readingStartedAt.current);
+ if (remaining > 0) await new Promise((resolve) => setTimeout(resolve, remaining));
+ };
+
+ const runDecode = async (uri: string, useGuideCrop = false) => {
try {
- setResult(await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined));
+ const payload = await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined);
+ await finishReadingBeat();
+ setResult(payload);
+ setPhase('success');
} catch {
+ await finishReadingBeat();
setError("Couldn't read Signal. Line the code up in the frame, hold steady, and try again.");
+ setPhase('failure');
} finally {
setBusy(false);
}
@@ -40,102 +72,163 @@ export default function SignalScanScreen() {
const capture = async () => {
if (busy) return;
- const photo = await cameraRef.current?.takePictureAsync({ quality: 1 });
- if (photo?.uri) await runDecode(photo.uri, true);
+ beginReading();
+ try {
+ const photo = await cameraRef.current?.takePictureAsync({ quality: 1 });
+ if (photo?.uri) {
+ await runDecode(photo.uri, true);
+ } else {
+ await finishReadingBeat();
+ setError("Couldn't capture that image. Hold steady and try again.");
+ setPhase('failure');
+ setBusy(false);
+ }
+ } catch {
+ await finishReadingBeat();
+ setError("Couldn't capture that image. Hold steady and try again.");
+ setPhase('failure');
+ setBusy(false);
+ }
};
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);
+ if (uri) {
+ beginReading();
+ await runDecode(uri);
+ }
};
+ const scanAnother = () => {
+ setResult(null);
+ setError(null);
+ setPhase('idle');
+ };
+ const standaloneResult = result !== null && !permission?.granted;
+
return (
router.back()} hitSlop={8}>
- Signal
+ Back
- Scan the Signal
+ Scan a Signal
-
- {!permission ? (
-
- ) : !permission.granted ? (
-
-
- Camera access is needed to scan a Signal.
- void requestPermission()}>
-
- Allow camera
-
-
- void pickImage()}>
-
- Or pick a Signal image
-
-
+ {standaloneResult ? (
+
+
+
+
+
+
+ Scan another
+
+
+ router.back()}>
+
+ Done
+
+
+
) : (
-
- setPreviewSize(event.nativeEvent.layout)}
- />
-
-
-
-
-
-
- {busy ? (
-
-
-
- Reading signal…
+ <>
+
+ Keep the full white Signal card visible and hold steady.
+
+
+ {currentTrack ? (
+ router.push('/signal' as never)}
+ accessibilityRole="button"
+ accessibilityLabel={`View the Signal for ${currentTrack.title} by ${currentTrack.artist}`}
+ >
+
+
+
+
+
+ NOW PLAYING
+
+
+ {currentTrack.title} · {currentTrack.artist}
+
+
+
+
+ ) : null}
+
+ {!permission ? (
+
+ ) : !permission.granted ? (
+
+
+ Camera access is needed to scan a Signal.
+ void requestPermission()}>
+
+ Allow camera
+
+
+ void pickImage()}>
+
+ Or pick a Signal image
+
+
+
+ ) : (
+
+ setPreviewSize(event.nativeEvent.layout)}
+ />
+
+
+
+
+
+
+
+ void pickImage()} hitSlop={8}>
+
+
+ void capture()} hitSlop={8}>
+
+
+
+
+ router.back()}
+ />
+
+ )}
+
+ {error && phase === 'idle' ? (
+
+
+
+ {error}
) : null}
-
- void pickImage()} hitSlop={8}>
-
-
- void capture()} hitSlop={8}>
-
-
-
-
-
+ >
)}
-
- {error ? (
-
-
-
- {error}
-
-
- ) : null}
-
- {result ? (
-
-
- setResult(null)}>
-
- Scan another
-
-
-
- ) : null}
);
}
@@ -151,7 +244,36 @@ const useStyles = createThemedStyles((colors) => ({
gap: 2,
},
heading: {
- marginBottom: spacing.lg,
+ marginBottom: spacing.xs,
+ },
+ instruction: {
+ marginBottom: spacing.md,
+ },
+ currentSignal: {
+ minHeight: 58,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.md,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm,
+ marginBottom: spacing.md,
+ borderRadius: radius.md,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.glassBg,
+ },
+ currentSignalIcon: {
+ width: 34,
+ height: 34,
+ borderRadius: 17,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: colors.accentGlow,
+ },
+ currentSignalCopy: {
+ flex: 1,
+ minWidth: 0,
+ gap: 1,
},
center: {
flex: 1,
@@ -171,6 +293,18 @@ const useStyles = createThemedStyles((colors) => ({
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
+ flexDirection: 'row',
+ gap: spacing.sm,
+ },
+ secondaryButton: {
+ minHeight: 44,
+ borderRadius: radius.sm,
+ backgroundColor: colors.bgSecondary,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ paddingHorizontal: spacing.lg,
+ alignItems: 'center',
+ justifyContent: 'center',
},
linkButton: {
alignItems: 'center',
@@ -227,17 +361,6 @@ const useStyles = createThemedStyles((colors) => ({
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,
@@ -277,8 +400,13 @@ const useStyles = createThemedStyles((colors) => ({
errorCopy: {
flex: 1,
},
- resultWrap: {
+ resultState: {
+ flex: 1,
+ justifyContent: 'center',
+ gap: spacing.xl,
+ paddingBottom: spacing.xxl,
+ },
+ resultActions: {
gap: spacing.md,
- marginBottom: spacing.lg,
},
}));
diff --git a/src/components/signal/SignalResultCard.tsx b/src/components/signal/SignalResultCard.tsx
index 191bc28..96b5f37 100644
--- a/src/components/signal/SignalResultCard.tsx
+++ b/src/components/signal/SignalResultCard.tsx
@@ -11,24 +11,30 @@ import type { SignalPayload } from '@boof2015/astra-signal';
* 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 }) {
+export function SignalResultCard({
+ payload,
+ compact = false,
+}: {
+ payload: SignalPayload;
+ compact?: boolean;
+}) {
const styles = useStyles();
const colors = useColors();
const title = payload.title.trim();
const artist = payload.artist.trim();
return (
-
-
-
-
- Signal found
-
+
+
+
+
+ SIGNAL FOUND
+
{title || 'Unknown title'}
-
+
{artist || 'Unknown artist'}
{payload.durationSec > 0 ? (
@@ -45,26 +51,45 @@ export function SignalResultCard({ payload }: { payload: SignalPayload }) {
const useStyles = createThemedStyles((colors) => ({
card: {
- borderRadius: radius.md,
+ minHeight: 230,
+ borderRadius: radius.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
- padding: spacing.lg,
- gap: spacing.xs,
- },
- badge: {
- flexDirection: 'row',
+ padding: spacing.xl,
+ gap: spacing.sm,
alignItems: 'center',
- gap: spacing.xs,
- alignSelf: 'flex-start',
- paddingHorizontal: spacing.sm,
- paddingVertical: 4,
- borderRadius: radius.pill,
+ justifyContent: 'center',
+ },
+ signalMark: {
+ width: 54,
+ height: 54,
+ borderRadius: 27,
+ alignItems: 'center',
+ justifyContent: 'center',
backgroundColor: colors.accentGlow,
marginBottom: spacing.sm,
},
+ compactCard: {
+ minHeight: 0,
+ padding: spacing.lg,
+ gap: spacing.xs,
+ },
+ compactSignalMark: {
+ width: 42,
+ height: 42,
+ borderRadius: 21,
+ marginBottom: spacing.xs,
+ },
+ foundLabel: {
+ letterSpacing: 0.8,
+ },
title: {
marginTop: spacing.xs,
+ textAlign: 'center',
+ },
+ artist: {
+ textAlign: 'center',
},
metaRow: {
flexDirection: 'row',
diff --git a/src/components/signal/SignalScanTransition.tsx b/src/components/signal/SignalScanTransition.tsx
new file mode 100644
index 0000000..8544659
--- /dev/null
+++ b/src/components/signal/SignalScanTransition.tsx
@@ -0,0 +1,482 @@
+import { useEffect, useMemo } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import Animated, {
+ Easing,
+ cancelAnimation,
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withDelay,
+ withRepeat,
+ withSequence,
+ withTiming,
+ type SharedValue,
+} from 'react-native-reanimated';
+import { encodeSignal, type SignalPayload } from '@boof2015/astra-signal';
+import { Text } from '@/components/Text';
+import { SignalCode } from '@/components/signal/SignalCode';
+import { SignalResultCard } from '@/components/signal/SignalResultCard';
+import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
+import { radius, spacing } from '@/theme';
+import { createThemedStyles, useColors } from '@/theme/themed';
+import { useRipple } from '@/theme/ripple';
+
+export type SignalScanPhase = 'idle' | 'reading' | 'success' | 'failure';
+
+interface SignalScanTransitionProps {
+ phase: SignalScanPhase;
+ width: number;
+ height: number;
+ payload: SignalPayload | null;
+ onScanAnother: () => void;
+ onDone: () => void;
+}
+
+const CODE_FG = '#0b0b12';
+const CODE_BG = '#f4f4f6';
+const PLACEHOLDER_COLUMNS = 40;
+const PLACEHOLDER_LEVELS = 4;
+const PLACEHOLDER_CHUNKS = 10;
+const PLACEHOLDER_STRUCTURES = 4;
+const PLACEHOLDER_TRANSITION_START = 0.12;
+const PLACEHOLDER_TRANSITION_END = 0.78;
+const PLACEHOLDER_UPPER_STRUCTURES = [
+ '32113131144213313314341341344212232232',
+ '12242324332343223433134344232332322424',
+ '34241341144211313421324134431321242123',
+ '24324314111412444413311432143222313213',
+] as const;
+const PLACEHOLDER_LOWER_STRUCTURES = [
+ '44324232322142244243343132331243424114',
+ '42221243244112214131412221131124441323',
+ '43111413331343431142314112233243121433',
+ '34441133421331212424112143414432243134',
+] as const;
+
+function placeholderChunkForIndex(index: number): number {
+ 'worklet';
+ const innerIndex = index - 1;
+ if (innerIndex < 4) return 0;
+ if (innerIndex < 7) return 1;
+ if (innerIndex < 12) return 2;
+ if (innerIndex < 14) return 3;
+ if (innerIndex < 18) return 4;
+ if (innerIndex < 23) return 5;
+ if (innerIndex < 26) return 6;
+ if (innerIndex < 30) return 7;
+ if (innerIndex < 33) return 8;
+ return 9;
+}
+
+function PlaceholderCell({
+ index,
+ level,
+ side,
+ cycle,
+}: {
+ index: number;
+ level: number;
+ side: 'upper' | 'lower';
+ cycle: SharedValue;
+}) {
+ const cellStyle = useAnimatedStyle(() => {
+ if (index === 0 || index === PLACEHOLDER_COLUMNS - 1) return { opacity: 1 };
+ const structureProgress = cycle.value * PLACEHOLDER_STRUCTURES;
+ const currentStructure = Math.floor(structureProgress) % PLACEHOLDER_STRUCTURES;
+ const nextStructure = (currentStructure + 1) % PLACEHOLDER_STRUCTURES;
+ const stageProgress = structureProgress - Math.floor(structureProgress);
+ const transitionProgress = Math.max(
+ 0,
+ Math.min(
+ 1,
+ (stageProgress - PLACEHOLDER_TRANSITION_START)
+ / (PLACEHOLDER_TRANSITION_END - PLACEHOLDER_TRANSITION_START)
+ )
+ );
+ const chunk = placeholderChunkForIndex(index);
+ const orderMultiplier = currentStructure % 2 === 0 ? 7 : 3;
+ const orderOffset = (currentStructure * 3 + 1) % PLACEHOLDER_CHUNKS;
+ const updatePosition = (chunk * orderMultiplier + orderOffset) % PLACEHOLDER_CHUNKS;
+ const timingJitter = (((updatePosition * 7 + currentStructure * 5) % 5) - 2) * 0.018;
+ const switchPoint = Math.max(
+ 0.04,
+ Math.min(0.96, (updatePosition + 0.5) / PLACEHOLDER_CHUNKS + timingJitter)
+ );
+ const structure = transitionProgress >= switchPoint ? nextStructure : currentStructure;
+ const structureLevels = side === 'upper'
+ ? PLACEHOLDER_UPPER_STRUCTURES[structure]
+ : PLACEHOLDER_LOWER_STRUCTURES[structure];
+ const activeLevels = structureLevels.charCodeAt(index - 1) - 48;
+ return { opacity: level < activeLevels ? 1 : 0 };
+ }, [index, level, side]);
+
+ return ;
+}
+
+function PlaceholderColumn({ index, cycle }: { index: number; cycle: SharedValue }) {
+ const levels = Array.from({ length: PLACEHOLDER_LEVELS }, (_, level) => level);
+ return (
+
+
+ {levels.map((level) => (
+
+ ))}
+
+
+ {levels.map((level) => (
+
+ ))}
+
+
+ );
+}
+
+export function SignalScanTransition({
+ phase,
+ width,
+ height,
+ payload,
+ onScanAnother,
+ onDone,
+}: SignalScanTransitionProps) {
+ const themedStyles = useStyles();
+ const colors = useColors();
+ const ripple = useRipple();
+ const overlayOpacity = useSharedValue(0);
+ const lockProgress = useSharedValue(0);
+ const settleProgress = useSharedValue(0);
+ const resultProgress = useSharedValue(0);
+ const actualOpacity = useSharedValue(0);
+ const placeholderOpacity = useSharedValue(1);
+ const failurePulse = useSharedValue(0);
+ const cycle = useSharedValue(0);
+
+ const layout = useMemo(() => (payload ? encodeSignal(payload) : null), [payload]);
+ const guideWidth = width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2);
+ const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio;
+ const guideLeft = width * SIGNAL_SCAN_GUIDE.horizontalInset;
+ const guideTop = height * SIGNAL_SCAN_GUIDE.top;
+ const targetAvailableWidth = Math.max(1, width - spacing.lg * 4);
+ const targetCodeWidth = layout
+ ? Math.min(
+ layout.tier === 'small' ? 280 : layout.tier === 'medium' ? 340 : targetAvailableWidth,
+ targetAvailableWidth
+ )
+ : targetAvailableWidth;
+ const targetCardWidth = targetCodeWidth + spacing.lg * 2;
+ const targetCardHeight = layout
+ ? (layout.heightModules / layout.widthModules) * targetCodeWidth + spacing.lg * 2
+ : guideHeight;
+ const targetLeft = (width - targetCardWidth) / 2;
+ const targetTop = spacing.lg;
+ const resultTop = targetTop + targetCardHeight + spacing.lg;
+
+ useEffect(() => {
+ if (phase === 'idle') {
+ cancelAnimation(cycle);
+ overlayOpacity.value = 0;
+ lockProgress.value = 0;
+ settleProgress.value = 0;
+ resultProgress.value = 0;
+ actualOpacity.value = 0;
+ placeholderOpacity.value = 1;
+ failurePulse.value = 0;
+ cycle.value = 0;
+ return;
+ }
+
+ if (phase === 'reading') {
+ overlayOpacity.value = withTiming(1, { duration: 150 });
+ lockProgress.value = withTiming(1, {
+ duration: 240,
+ easing: Easing.out(Easing.cubic),
+ });
+ settleProgress.value = 0;
+ resultProgress.value = 0;
+ actualOpacity.value = 0;
+ placeholderOpacity.value = 1;
+ failurePulse.value = 0;
+ cycle.value = 0;
+ cycle.value = withRepeat(
+ withTiming(1, { duration: 4800, easing: Easing.linear }),
+ -1,
+ false
+ );
+ return;
+ }
+
+ if (phase === 'success') {
+ cancelAnimation(cycle);
+ actualOpacity.value = withTiming(1, { duration: 240 });
+ placeholderOpacity.value = withTiming(0, { duration: 220 });
+ settleProgress.value = withDelay(
+ 100,
+ withTiming(1, { duration: 340, easing: Easing.out(Easing.cubic) })
+ );
+ resultProgress.value = withDelay(
+ 300,
+ withTiming(1, { duration: 280, easing: Easing.out(Easing.cubic) })
+ );
+ return;
+ }
+
+ cancelAnimation(cycle);
+ failurePulse.value = withSequence(
+ withTiming(1, { duration: 120 }),
+ withTiming(0, { duration: 180 })
+ );
+ placeholderOpacity.value = withTiming(0, { duration: 180 });
+ lockProgress.value = withDelay(80, withTiming(0, { duration: 240 }));
+ overlayOpacity.value = withDelay(220, withTiming(0, { duration: 220 }));
+ }, [
+ actualOpacity,
+ cycle,
+ failurePulse,
+ lockProgress,
+ overlayOpacity,
+ phase,
+ placeholderOpacity,
+ resultProgress,
+ settleProgress,
+ ]);
+
+ const overlayStyle = useAnimatedStyle(() => ({ opacity: overlayOpacity.value }));
+ const guideStyle = useAnimatedStyle(() => ({
+ opacity: 1 - lockProgress.value,
+ transform: [{ scaleX: interpolate(lockProgress.value, [0, 1], [1, 0.84]) }],
+ }));
+ const cardStyle = useAnimatedStyle(() => ({
+ left: interpolate(settleProgress.value, [0, 1], [guideLeft, targetLeft]),
+ top: interpolate(settleProgress.value, [0, 1], [guideTop, targetTop]),
+ width: interpolate(settleProgress.value, [0, 1], [guideWidth, targetCardWidth]),
+ height: interpolate(settleProgress.value, [0, 1], [guideHeight, targetCardHeight]),
+ opacity: lockProgress.value,
+ transform: [{ scale: interpolate(lockProgress.value, [0, 1], [0.94, 1]) }],
+ }), [guideHeight, guideLeft, guideTop, guideWidth, targetCardHeight, targetCardWidth, targetLeft, targetTop]);
+ const placeholderStyle = useAnimatedStyle(() => ({ opacity: placeholderOpacity.value }));
+ const actualStyle = useAnimatedStyle(() => ({ opacity: actualOpacity.value }));
+ const failureStyle = useAnimatedStyle(() => ({ opacity: failurePulse.value }));
+ const resultStyle = useAnimatedStyle(() => ({
+ opacity: resultProgress.value,
+ transform: [{ translateY: interpolate(resultProgress.value, [0, 1], [18, 0]) }],
+ }));
+
+ if (phase === 'idle' || width <= 0 || height <= 0) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: PLACEHOLDER_COLUMNS }, (_, index) => (
+
+ ))}
+
+
+ {layout ? (
+
+
+
+ ) : null}
+
+
+
+
+ {payload ? (
+
+
+
+
+
+
+ Scan another
+
+
+
+
+ Done
+
+
+
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ placeholderField: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ marginHorizontal: spacing.lg,
+ marginVertical: spacing.md,
+ flexDirection: 'row',
+ },
+ placeholderColumn: {
+ flex: 1,
+ height: '100%',
+ },
+ placeholderStack: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ height: '39%',
+ },
+ placeholderUpperStack: {
+ bottom: '50%',
+ flexDirection: 'column-reverse',
+ },
+ placeholderLowerStack: {
+ top: '50%',
+ },
+ placeholderCell: {
+ flex: 1,
+ marginHorizontal: -StyleSheet.hairlineWidth,
+ marginVertical: -StyleSheet.hairlineWidth,
+ backgroundColor: CODE_FG,
+ },
+ actualSignal: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+const useStyles = createThemedStyles((colors) => ({
+ overlay: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ zIndex: 20,
+ overflow: 'hidden',
+ backgroundColor: colors.bgPrimary,
+ },
+ guide: {
+ position: 'absolute',
+ },
+ 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,
+ },
+ carrier: {
+ position: 'absolute',
+ overflow: 'hidden',
+ borderRadius: radius.lg,
+ backgroundColor: CODE_BG,
+ elevation: 8,
+ shadowColor: '#000000',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.25,
+ shadowRadius: 16,
+ },
+ failureOutline: {
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ borderWidth: 3,
+ borderRadius: radius.lg,
+ borderColor: colors.warning,
+ },
+ resultPanel: {
+ position: 'absolute',
+ left: spacing.lg,
+ right: spacing.lg,
+ gap: spacing.md,
+ },
+ resultActions: {
+ flexDirection: 'row',
+ gap: spacing.md,
+ },
+ primaryButton: {
+ minHeight: 48,
+ flex: 1.35,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: spacing.sm,
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.sm,
+ backgroundColor: colors.accent,
+ },
+ secondaryButton: {
+ minHeight: 48,
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: spacing.md,
+ borderRadius: radius.sm,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.glassBorder,
+ backgroundColor: colors.bgSecondary,
+ },
+}));