mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 12:14:47 +02:00
signal local library search
This commit is contained in:
@@ -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