mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
signal local library search
This commit is contained in:
+1
-1
@@ -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
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user