signal local library search

This commit is contained in:
Boof2015
2026-07-15 10:04:51 -04:00
parent e6b2af0ae2
commit 0cb36167ba
7 changed files with 602 additions and 154 deletions
+1 -1
View File
@@ -73,7 +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: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 src/audio/signalLocalMatch.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",
+65 -85
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import * as DocumentPicker from 'expo-document-picker';
@@ -6,14 +6,21 @@ 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 {
SignalResolutionPanel,
type SignalResultActionState,
} from '@/components/signal/SignalResolutionPanel';
import {
SignalScanTransition,
type SignalScanPhase,
} from '@/components/signal/SignalScanTransition';
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
import { matchSignalToLibrary } from '@/audio/signalLocalMatch';
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
import { usePlayerStore } from '@/stores/playerStore';
import { enqueueEnd, playTracks } from '@/audio/playbackController';
import { dbTrackToTrack } from '@/library/trackAdapter';
import { playHaptic } from '@/lib/haptics';
import { useLibraryStore } from '@/stores/libraryStore';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
@@ -27,15 +34,22 @@ export default function SignalScanScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
const currentTrack = usePlayerStore((state) => state.currentTrack);
const libraryInitialized = useLibraryStore((state) => state.initialized);
const libraryTracks = useLibraryStore((state) => state.tracks);
const cameraRef = useRef<CameraView>(null);
const [permission, requestPermission] = useCameraPermissions();
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<SignalPayload | null>(null);
const [phase, setPhase] = useState<SignalScanPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [actionState, setActionState] = useState<SignalResultActionState>('idle');
const [actionError, setActionError] = useState<string | null>(null);
const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 });
const readingStartedAt = useRef(0);
const resolution = useMemo(
() => result && libraryInitialized ? matchSignalToLibrary(result, libraryTracks) : null,
[libraryInitialized, libraryTracks, result]
);
useEffect(() => {
if (phase !== 'failure') return;
@@ -104,9 +118,52 @@ export default function SignalScanScreen() {
const scanAnother = () => {
setResult(null);
setError(null);
setActionState('idle');
setActionError(null);
setPhase('idle');
};
const playMatchedTrack = async (track: (typeof libraryTracks)[number]) => {
if (actionState === 'playing' || actionState === 'queueing') return;
playHaptic('confirm');
setActionState('playing');
setActionError(null);
try {
await playTracks([dbTrackToTrack(track)]);
router.back();
} catch {
setActionState('idle');
setActionError("Couldn't start this track. Try playing it from your library.");
}
};
const queueMatchedTrack = async (track: (typeof libraryTracks)[number]) => {
if (actionState !== 'idle') return;
playHaptic('confirm');
setActionState('queueing');
setActionError(null);
try {
await enqueueEnd(dbTrackToTrack(track));
setActionState('queued');
} catch {
setActionState('idle');
setActionError("Couldn't add this track to the queue.");
}
};
const standaloneResult = result !== null && !permission?.granted;
const resultContent = result ? (
<SignalResolutionPanel
payload={result}
resolution={resolution}
actionState={actionState}
actionError={actionError}
onPlay={(track) => void playMatchedTrack(track)}
onQueue={(track) => void queueMatchedTrack(track)}
onScanAnother={scanAnother}
onDone={() => router.back()}
/>
) : null;
return (
<Screen>
@@ -124,20 +181,7 @@ export default function SignalScanScreen() {
</Text>
{standaloneResult ? (
<View style={styles.resultState}>
<SignalResultCard payload={result} />
<View style={styles.resultActions}>
<Pressable android_ripple={ripple.bounded} style={styles.primaryButton} onPress={scanAnother}>
<Ionicons name="scan-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Scan another
</Text>
</Pressable>
<Pressable android_ripple={ripple.bounded} style={styles.secondaryButton} onPress={() => router.back()}>
<Text variant="body" color={colors.textPrimary}>
Done
</Text>
</Pressable>
</View>
{resultContent}
</View>
) : (
<>
@@ -145,29 +189,6 @@ export default function SignalScanScreen() {
Keep the full white Signal card visible and hold steady.
</Text>
{currentTrack ? (
<Pressable
android_ripple={ripple.bounded}
style={styles.currentSignal}
onPress={() => router.push('/signal' as never)}
accessibilityRole="button"
accessibilityLabel={`View the Signal for ${currentTrack.title} by ${currentTrack.artist}`}
>
<View style={styles.currentSignalIcon}>
<Ionicons name="pulse" size={18} color={colors.accent} />
</View>
<View style={styles.currentSignalCopy}>
<Text variant="label" color={colors.textTertiary}>
NOW PLAYING
</Text>
<Text variant="body" numberOfLines={1}>
{currentTrack.title} · {currentTrack.artist}
</Text>
</View>
<Ionicons name="chevron-forward" size={19} color={colors.textTertiary} />
</Pressable>
) : null}
{!permission ? (
<View style={styles.center} />
) : !permission.granted ? (
@@ -213,8 +234,7 @@ export default function SignalScanScreen() {
width={previewSize.width}
height={previewSize.height}
payload={result}
onScanAnother={scanAnother}
onDone={() => router.back()}
resultContent={resultContent}
/>
</View>
)}
@@ -249,32 +269,6 @@ const useStyles = createThemedStyles((colors) => ({
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,
},
@@ -296,16 +290,6 @@ const useStyles = createThemedStyles((colors) => ({
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',
paddingVertical: spacing.sm,
@@ -402,11 +386,7 @@ const useStyles = createThemedStyles((colors) => ({
},
resultState: {
flex: 1,
justifyContent: 'center',
gap: spacing.xl,
paddingBottom: spacing.xxl,
},
resultActions: {
gap: spacing.md,
paddingTop: spacing.lg,
paddingBottom: spacing.lg,
},
}));
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { matchSignalToLibrary, type SignalMatchableTrack } from './signalLocalMatch.ts';
interface TestTrack extends SignalMatchableTrack {
album: string;
}
function track(overrides: Partial<TestTrack> = {}): TestTrack {
return {
path: '/music/replay.flac',
title: 'Replay',
artist: 'ナナツカゼ',
album: 'Signal Tests',
duration: 213.6,
...overrides,
};
}
test('matches Unicode and punctuation without losing identity', () => {
const candidate = track({ artist: 'N!GHT', title: '#iwannadance', duration: 222.3 });
const result = matchSignalToLibrary(
{ artist: 'N!GHT', title: '#iwannadance', durationSec: 222 },
[candidate]
);
assert.equal(result.kind, 'match');
if (result.kind !== 'match') return;
assert.equal(result.candidate.track, candidate);
assert.equal(result.candidate.match, 'exact');
});
test('uses duration to reject a different version of an exact metadata match', () => {
const result = matchSignalToLibrary(
{ artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 },
[track(), track({ path: '/music/replay-live.flac', duration: 278 })]
);
assert.equal(result.kind, 'match');
if (result.kind !== 'match') return;
assert.equal(result.candidate.track.path, '/music/replay.flac');
});
test('returns ambiguity instead of guessing between equivalent local versions', () => {
const result = matchSignalToLibrary(
{ artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 },
[
track({ path: '/music/replay-flac.flac', album: 'Replay', duration: 213.6 }),
track({ path: '/music/replay-mp3.mp3', album: 'Singles', duration: 214.1 }),
]
);
assert.equal(result.kind, 'ambiguous');
if (result.kind !== 'ambiguous') return;
assert.deepEqual(result.candidates.map((entry) => entry.track.path), [
'/music/replay-mp3.mp3',
'/music/replay-flac.flac',
]);
});
test('allows a duration-gated punctuation and diacritic fallback', () => {
const candidate = track({ artist: 'Beyoncé', title: 'Déjà Vu', duration: 223.4 });
const result = matchSignalToLibrary(
{ artist: 'Beyonce', title: 'Deja Vu', durationSec: 223 },
[candidate]
);
assert.equal(result.kind, 'match');
if (result.kind !== 'match') return;
assert.equal(result.candidate.match, 'normalized');
});
test('prefers punctuation-preserving candidates over a closer relaxed candidate', () => {
const exact = track({ path: '/music/night.flac', artist: 'N!GHT', title: 'Replay', duration: 221.8 });
const relaxed = track({ path: '/music/n-ght.flac', artist: 'N GHT', title: 'Replay', duration: 220 });
const result = matchSignalToLibrary(
{ artist: 'N!GHT', title: 'Replay', durationSec: 220 },
[relaxed, exact]
);
assert.equal(result.kind, 'match');
if (result.kind !== 'match') return;
assert.equal(result.candidate.track, exact);
});
test('does not relax empty or duration-free metadata into a plausible match', () => {
assert.deepEqual(
matchSignalToLibrary({ artist: '', title: 'Replay', durationSec: 214 }, [track()]),
{ kind: 'none' }
);
assert.deepEqual(
matchSignalToLibrary(
{ artist: 'N GHT', title: 'Replay', durationSec: 0 },
[track({ artist: 'N!GHT' })]
),
{ kind: 'none' }
);
});
test('does not ignore a Signal duration when the library duration is unavailable', () => {
assert.deepEqual(
matchSignalToLibrary(
{ artist: 'ナナツカゼ', title: 'Replay', durationSec: 214 },
[track({ duration: 0 })]
),
{ kind: 'none' }
);
});
+111
View File
@@ -0,0 +1,111 @@
import type { SignalPayload } from '@boof2015/astra-signal';
export interface SignalMatchableTrack {
path: string;
title: string;
artist: string;
duration: number;
}
export interface SignalLocalCandidate<T extends SignalMatchableTrack> {
track: T;
match: 'exact' | 'normalized';
durationDeltaSec: number | null;
}
export type SignalLocalMatchResult<T extends SignalMatchableTrack> =
| { kind: 'match'; candidate: SignalLocalCandidate<T> }
| { kind: 'ambiguous'; candidates: SignalLocalCandidate<T>[] }
| { kind: 'none' };
type SignalIdentity = Pick<SignalPayload, 'artist' | 'title' | 'durationSec'>;
const EXACT_DURATION_TOLERANCE_SEC = 3;
const NORMALIZED_DURATION_TOLERANCE_SEC = 2;
function exactTextForm(value: string): string {
return value.normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase();
}
function relaxedTextForm(value: string): string {
return value
.normalize('NFKD')
.replace(/\p{M}+/gu, '')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function usableDuration(value: number): number | null {
return Number.isFinite(value) && value > 0 ? value : null;
}
function byDurationThenPath<T extends SignalMatchableTrack>(
left: SignalLocalCandidate<T>,
right: SignalLocalCandidate<T>
): number {
const leftDelta = left.durationDeltaSec ?? Number.POSITIVE_INFINITY;
const rightDelta = right.durationDeltaSec ?? Number.POSITIVE_INFINITY;
return leftDelta - rightDelta || left.track.path.localeCompare(right.track.path);
}
function resultFromCandidates<T extends SignalMatchableTrack>(
candidates: SignalLocalCandidate<T>[]
): SignalLocalMatchResult<T> {
candidates.sort(byDurationThenPath);
if (candidates.length === 0) return { kind: 'none' };
if (candidates.length === 1) return { kind: 'match', candidate: candidates[0] };
return { kind: 'ambiguous', candidates };
}
/**
* Resolve a decoded Signal against the already-loaded on-device library.
*
* Punctuation-preserving comparisons always win. A punctuation/diacritic-
* insensitive fallback is only accepted when both sides have usable, closely
* matching durations, so names such as N!GHT retain their stronger identity.
*/
export function matchSignalToLibrary<T extends SignalMatchableTrack>(
signal: SignalIdentity,
tracks: readonly T[]
): SignalLocalMatchResult<T> {
const signalTitle = exactTextForm(signal.title);
const signalArtist = exactTextForm(signal.artist);
if (!signalTitle || !signalArtist) return { kind: 'none' };
const relaxedSignalTitle = relaxedTextForm(signal.title);
const relaxedSignalArtist = relaxedTextForm(signal.artist);
const signalDuration = usableDuration(signal.durationSec);
const exact: SignalLocalCandidate<T>[] = [];
const normalized: SignalLocalCandidate<T>[] = [];
for (const track of tracks) {
const title = exactTextForm(track.title);
const artist = exactTextForm(track.artist);
const trackDuration = usableDuration(track.duration);
const durationDeltaSec = signalDuration !== null && trackDuration !== null
? Math.abs(signalDuration - trackDuration)
: null;
if (title === signalTitle && artist === signalArtist) {
if (signalDuration !== null && trackDuration === null) continue;
if (durationDeltaSec !== null && durationDeltaSec > EXACT_DURATION_TOLERANCE_SEC) continue;
exact.push({ track, match: 'exact', durationDeltaSec });
continue;
}
// Relaxed matching without duration would be too eager: punctuation can be
// meaningful artist/title data, and a false local match is worse than none.
if (signalDuration === null || trackDuration === null) continue;
if (durationDeltaSec === null || durationDeltaSec > NORMALIZED_DURATION_TOLERANCE_SEC) continue;
if (
relaxedTextForm(track.title) === relaxedSignalTitle
&& relaxedTextForm(track.artist) === relaxedSignalArtist
) {
normalized.push({ track, match: 'normalized', durationDeltaSec });
}
}
return resultFromCandidates(exact.length > 0 ? exact : normalized);
}
@@ -0,0 +1,307 @@
import { Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { SignalResultCard } from '@/components/signal/SignalResultCard';
import type { SignalLocalMatchResult } from '@/audio/signalLocalMatch';
import type { DbTrack } from '@/types/library';
import type { SignalPayload } from '@boof2015/astra-signal';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
export type SignalResultActionState = 'idle' | 'playing' | 'queueing' | 'queued';
interface SignalResolutionPanelProps {
payload: SignalPayload;
resolution: SignalLocalMatchResult<DbTrack> | null;
actionState: SignalResultActionState;
actionError: string | null;
onPlay: (track: DbTrack) => void;
onQueue: (track: DbTrack) => void;
onScanAnother: () => void;
onDone: () => void;
}
export function SignalResolutionPanel({
payload,
resolution,
actionState,
actionError,
onPlay,
onQueue,
onScanAnother,
onDone,
}: SignalResolutionPanelProps) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
const actionBusy = actionState === 'playing' || actionState === 'queueing';
const footer = (
<View style={styles.footerActions}>
<Pressable
android_ripple={ripple.bounded}
style={styles.footerButton}
onPress={onScanAnother}
disabled={actionBusy}
accessibilityRole="button"
>
<Ionicons name="scan-outline" size={17} color={colors.textSecondary} />
<Text variant="label">Scan another</Text>
</Pressable>
<Pressable
android_ripple={ripple.bounded}
style={styles.footerButton}
onPress={onDone}
disabled={actionBusy}
accessibilityRole="button"
>
<Text variant="label">Done</Text>
</Pressable>
</View>
);
if (!resolution) {
return (
<View style={styles.root}>
<SignalResultCard payload={payload} compact />
<View style={styles.statusLine}>
<Ionicons name="library-outline" size={18} color={colors.textTertiary} />
<Text variant="body" color={colors.textSecondary}>
Checking your library
</Text>
</View>
{footer}
</View>
);
}
if (resolution.kind === 'none') {
return (
<View style={styles.root}>
<SignalResultCard payload={payload} compact />
<View style={styles.messageCard}>
<Ionicons name="library-outline" size={21} color={colors.textTertiary} />
<View style={styles.messageCopy}>
<Text variant="heading">Not in your library</Text>
<Text variant="label">
The Signal decoded correctly, but no local track matched.
</Text>
</View>
</View>
{footer}
</View>
);
}
if (resolution.kind === 'ambiguous') {
return (
<View style={styles.root}>
<View style={styles.sectionHeading}>
<View style={styles.matchIcon}>
<Ionicons name="library" size={18} color={colors.accent} />
</View>
<View style={styles.headingCopy}>
<Text variant="label" color={colors.accent} style={styles.eyebrow}>
FOUND IN YOUR LIBRARY
</Text>
<Text variant="heading">Choose a version</Text>
</View>
</View>
<ScrollView
style={styles.candidateList}
nestedScrollEnabled
showsVerticalScrollIndicator={resolution.candidates.length > 3}
>
{resolution.candidates.map(({ track }) => (
<TrackRow
key={track.path}
track={track}
subtitle={track.album}
swipeToQueue={false}
onPress={() => onPlay(track)}
/>
))}
</ScrollView>
<Text variant="caption" style={styles.centerCopy}>
Tap a version to play it.
</Text>
{actionError ? (
<Text variant="label" color={colors.warning} style={styles.centerCopy}>
{actionError}
</Text>
) : null}
{footer}
</View>
);
}
const track = resolution.candidate.track;
return (
<View style={styles.root}>
<View style={styles.sectionHeading}>
<View style={styles.matchIcon}>
<Ionicons name="checkmark" size={20} color={colors.accent} />
</View>
<View style={styles.headingCopy}>
<Text variant="label" color={colors.accent} style={styles.eyebrow}>
IN YOUR LIBRARY
</Text>
<Text variant="heading">Ready to play</Text>
</View>
</View>
<TrackRow
track={track}
subtitle={track.album}
swipeToQueue={false}
onPress={() => onPlay(track)}
/>
<View style={styles.playActions}>
<Pressable
android_ripple={ripple.bounded}
style={[styles.primaryButton, actionBusy && styles.disabledButton]}
onPress={() => onPlay(track)}
disabled={actionBusy}
accessibilityRole="button"
>
<Ionicons name="play" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
{actionState === 'playing' ? 'Starting…' : 'Play now'}
</Text>
</Pressable>
<Pressable
android_ripple={ripple.bounded}
style={[styles.secondaryButton, actionBusy && styles.disabledButton]}
onPress={() => onQueue(track)}
disabled={actionBusy || actionState === 'queued'}
accessibilityRole="button"
>
<Ionicons
name={actionState === 'queued' ? 'checkmark' : 'list-outline'}
size={18}
color={colors.textPrimary}
/>
<Text variant="body">
{actionState === 'queueing'
? 'Adding…'
: actionState === 'queued'
? 'Added'
: 'Add to queue'}
</Text>
</Pressable>
</View>
{actionError ? (
<Text variant="label" color={colors.warning} style={styles.centerCopy}>
{actionError}
</Text>
) : null}
{footer}
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
root: {
flexShrink: 1,
gap: spacing.md,
},
sectionHeading: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
matchIcon: {
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accentGlow,
},
headingCopy: {
flex: 1,
minWidth: 0,
gap: 2,
},
eyebrow: {
letterSpacing: 0.7,
},
statusLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
messageCard: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.md,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
},
messageCopy: {
flex: 1,
minWidth: 0,
gap: spacing.xs,
},
candidateList: {
flexShrink: 1,
maxHeight: 226,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.glassBorder,
},
centerCopy: {
textAlign: 'center',
},
playActions: {
flexDirection: 'row',
gap: spacing.sm,
},
primaryButton: {
minHeight: 48,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: colors.accent,
},
secondaryButton: {
minHeight: 48,
flex: 1.15,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.bgSecondary,
},
disabledButton: {
opacity: 0.6,
},
footerActions: {
flexDirection: 'row',
gap: spacing.sm,
},
footerButton: {
minHeight: 40,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
borderRadius: radius.sm,
},
}));
+1 -5
View File
@@ -6,11 +6,7 @@ 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.
*/
/** Shows the database-free metadata recovered from a scanned/imported Signal. */
export function SignalResultCard({
payload,
compact = false,
+14 -63
View File
@@ -1,6 +1,5 @@
import { useEffect, useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useEffect, useMemo, type ReactNode } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
Easing,
cancelAnimation,
@@ -14,13 +13,10 @@ import Animated, {
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';
import { createThemedStyles } from '@/theme/themed';
export type SignalScanPhase = 'idle' | 'reading' | 'success' | 'failure';
@@ -29,8 +25,7 @@ interface SignalScanTransitionProps {
width: number;
height: number;
payload: SignalPayload | null;
onScanAnother: () => void;
onDone: () => void;
resultContent: ReactNode;
}
const CODE_FG = '#0b0b12';
@@ -137,12 +132,9 @@ export function SignalScanTransition({
width,
height,
payload,
onScanAnother,
onDone,
resultContent,
}: SignalScanTransitionProps) {
const themedStyles = useStyles();
const colors = useColors();
const ripple = useRipple();
const overlayOpacity = useSharedValue(0);
const lockProgress = useSharedValue(0);
const settleProgress = useSharedValue(0);
@@ -171,6 +163,7 @@ export function SignalScanTransition({
const targetLeft = (width - targetCardWidth) / 2;
const targetTop = spacing.lg;
const resultTop = targetTop + targetCardHeight + spacing.lg;
const resultMaxHeight = Math.max(1, height - resultTop - spacing.lg);
useEffect(() => {
if (phase === 'idle') {
@@ -304,29 +297,14 @@ export function SignalScanTransition({
</Animated.View>
{payload ? (
<Animated.View style={[themedStyles.resultPanel, { top: resultTop }, resultStyle]}>
<SignalResultCard payload={payload} compact />
<View style={themedStyles.resultActions}>
<Pressable
android_ripple={ripple.bounded}
style={themedStyles.primaryButton}
onPress={onScanAnother}
>
<Ionicons name="scan-outline" size={18} color={colors.accentTextStrong} />
<Text variant="body" color={colors.accentTextStrong}>
Scan another
</Text>
</Pressable>
<Pressable
android_ripple={ripple.bounded}
style={themedStyles.secondaryButton}
onPress={onDone}
>
<Text variant="body" color={colors.textPrimary}>
Done
</Text>
</Pressable>
</View>
<Animated.View
style={[
themedStyles.resultPanel,
{ top: resultTop, maxHeight: resultMaxHeight },
resultStyle,
]}
>
{resultContent}
</Animated.View>
) : null}
</Animated.View>
@@ -451,32 +429,5 @@ const useStyles = createThemedStyles((colors) => ({
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,
},
}));