mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 20:49:47 +02:00
add signal online searching
This commit is contained in:
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Pressable, StyleSheet, View } from 'react-native';
|
import { Linking, Pressable, StyleSheet, View } from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
|
import { decodeSignalFromUri } from '@/audio/signalDecodeImage';
|
||||||
import { matchSignalToLibrary } from '@/audio/signalLocalMatch';
|
import { matchSignalToLibrary } from '@/audio/signalLocalMatch';
|
||||||
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
|
import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry';
|
||||||
|
import { encodeSignalWebUrl } from '@/audio/signalShare';
|
||||||
import { enqueueEnd, playTracks } from '@/audio/playbackController';
|
import { enqueueEnd, playTracks } from '@/audio/playbackController';
|
||||||
import { dbTrackToTrack } from '@/library/trackAdapter';
|
import { dbTrackToTrack } from '@/library/trackAdapter';
|
||||||
import { playHaptic } from '@/lib/haptics';
|
import { playHaptic } from '@/lib/haptics';
|
||||||
@@ -26,7 +27,7 @@ import { createThemedStyles, useColors } from '@/theme/themed';
|
|||||||
import { useRipple } from '@/theme/ripple';
|
import { useRipple } from '@/theme/ripple';
|
||||||
import type { SignalPayload } from '@boof2015/astra-signal';
|
import type { SignalPayload } from '@boof2015/astra-signal';
|
||||||
|
|
||||||
const MIN_READING_MS = 600;
|
const MIN_READING_MS = 300;
|
||||||
const FAILURE_RETURN_MS = 480;
|
const FAILURE_RETURN_MS = 480;
|
||||||
|
|
||||||
export default function SignalScanScreen() {
|
export default function SignalScanScreen() {
|
||||||
@@ -151,6 +152,16 @@ export default function SignalScanScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOnline = async (payload: SignalPayload) => {
|
||||||
|
playHaptic('action');
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await Linking.openURL(encodeSignalWebUrl(payload));
|
||||||
|
} catch {
|
||||||
|
setActionError("Couldn't open the online Signal lookup.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const standaloneResult = result !== null && !permission?.granted;
|
const standaloneResult = result !== null && !permission?.granted;
|
||||||
const resultContent = result ? (
|
const resultContent = result ? (
|
||||||
<SignalResolutionPanel
|
<SignalResolutionPanel
|
||||||
@@ -160,6 +171,7 @@ export default function SignalScanScreen() {
|
|||||||
actionError={actionError}
|
actionError={actionError}
|
||||||
onPlay={(track) => void playMatchedTrack(track)}
|
onPlay={(track) => void playMatchedTrack(track)}
|
||||||
onQueue={(track) => void queueMatchedTrack(track)}
|
onQueue={(track) => void queueMatchedTrack(track)}
|
||||||
|
onFindOnline={() => void findOnline(result)}
|
||||||
onScanAnother={scanAnother}
|
onScanAnother={scanAnother}
|
||||||
onDone={() => router.back()}
|
onDone={() => router.back()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy';
|
import { File } from 'expo-file-system';
|
||||||
import { AlphaType, ColorType, Skia, rect, type SkImage } from '@shopify/react-native-skia';
|
import { AlphaType, ColorType, Skia, rect, type SkImage } from '@shopify/react-native-skia';
|
||||||
import {
|
import {
|
||||||
decodeSignalImage,
|
decodeSignalImage,
|
||||||
@@ -66,8 +66,8 @@ export async function decodeSignalFromUri(
|
|||||||
uri: string,
|
uri: string,
|
||||||
options: DecodeSignalFromUriOptions = {}
|
options: DecodeSignalFromUriOptions = {}
|
||||||
): Promise<SignalPayload> {
|
): Promise<SignalPayload> {
|
||||||
const b64 = await readAsStringAsync(uri, { encoding: EncodingType.Base64 });
|
const bytes = await new File(uri).bytes();
|
||||||
const encoded = Skia.Data.fromBase64(b64);
|
const encoded = Skia.Data.fromBytes(bytes);
|
||||||
const source = Skia.Image.MakeImageFromEncoded(encoded);
|
const source = Skia.Image.MakeImageFromEncoded(encoded);
|
||||||
encoded.dispose();
|
encoded.dispose();
|
||||||
if (!source) throw new Error('Could not read that image.');
|
if (!source) throw new Error('Could not read that image.');
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Track } from '../types/audio.ts';
|
|||||||
import {
|
import {
|
||||||
SIGNAL_LINK_PREFIX,
|
SIGNAL_LINK_PREFIX,
|
||||||
decodeTrackSignalLink,
|
decodeTrackSignalLink,
|
||||||
|
encodeSignalWebUrl,
|
||||||
encodeTrackSignalLink,
|
encodeTrackSignalLink,
|
||||||
signalInputFromTrack,
|
signalInputFromTrack,
|
||||||
signalLayoutFromTrack,
|
signalLayoutFromTrack,
|
||||||
@@ -62,6 +63,19 @@ test('preserves ASCII case and punctuation in track metadata', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builds a web resolver URL with the complete Signal in the fragment', () => {
|
||||||
|
const url = encodeSignalWebUrl({ artist: 'ナナツカゼ', title: 'Replay!', durationSec: 201 });
|
||||||
|
assert.match(url, /^https:\/\/astramusic\.dev\/signal\/#astra:signal:v3:/);
|
||||||
|
const signalLink = new URL(url).hash.slice(1);
|
||||||
|
assert.deepEqual(decodeTrackSignalLink(signalLink), {
|
||||||
|
version: 3,
|
||||||
|
type: 'metadata',
|
||||||
|
artist: 'ナナツカゼ',
|
||||||
|
title: 'Replay!',
|
||||||
|
durationSec: 201,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects non-v3, malformed, and CRC-corrupted links', () => {
|
test('rejects non-v3, malformed, and CRC-corrupted links', () => {
|
||||||
assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/);
|
assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/);
|
||||||
assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`));
|
assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`));
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { Track } from '../types/audio';
|
|||||||
|
|
||||||
export { SIGNAL_LINK_PREFIX };
|
export { SIGNAL_LINK_PREFIX };
|
||||||
|
|
||||||
|
export const SIGNAL_WEB_URL = 'https://astramusic.dev/signal/';
|
||||||
|
|
||||||
/** Convert local track metadata into the database-free v3 Signal input. */
|
/** Convert local track metadata into the database-free v3 Signal input. */
|
||||||
export function signalInputFromTrack(track: Track): SignalInput {
|
export function signalInputFromTrack(track: Track): SignalInput {
|
||||||
return {
|
return {
|
||||||
@@ -30,6 +32,11 @@ export function encodeTrackSignalLink(track: Track): string {
|
|||||||
return encodeLibrarySignalLink(signalInputFromTrack(track));
|
return encodeLibrarySignalLink(signalInputFromTrack(track));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the privacy-preserving web resolver URL. The Signal stays in the fragment. */
|
||||||
|
export function encodeSignalWebUrl(input: SignalInput): string {
|
||||||
|
return `${SIGNAL_WEB_URL}#${encodeLibrarySignalLink(input)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeTrackSignalLink(value: string): SignalPayload {
|
export function decodeTrackSignalLink(value: string): SignalPayload {
|
||||||
return decodeLibrarySignalLink(value);
|
return decodeLibrarySignalLink(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface SignalResolutionPanelProps {
|
|||||||
actionError: string | null;
|
actionError: string | null;
|
||||||
onPlay: (track: DbTrack) => void;
|
onPlay: (track: DbTrack) => void;
|
||||||
onQueue: (track: DbTrack) => void;
|
onQueue: (track: DbTrack) => void;
|
||||||
|
onFindOnline: () => void;
|
||||||
onScanAnother: () => void;
|
onScanAnother: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,7 @@ export function SignalResolutionPanel({
|
|||||||
actionError,
|
actionError,
|
||||||
onPlay,
|
onPlay,
|
||||||
onQueue,
|
onQueue,
|
||||||
|
onFindOnline,
|
||||||
onScanAnother,
|
onScanAnother,
|
||||||
onDone,
|
onDone,
|
||||||
}: SignalResolutionPanelProps) {
|
}: SignalResolutionPanelProps) {
|
||||||
@@ -38,6 +40,29 @@ export function SignalResolutionPanel({
|
|||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
const actionBusy = actionState === 'playing' || actionState === 'queueing';
|
const actionBusy = actionState === 'playing' || actionState === 'queueing';
|
||||||
|
|
||||||
|
const onlineAction = (primary = false) => (
|
||||||
|
<Pressable
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
style={[
|
||||||
|
styles.onlineButton,
|
||||||
|
primary ? styles.onlineButtonPrimary : styles.onlineButtonSecondary,
|
||||||
|
actionBusy && styles.disabledButton,
|
||||||
|
]}
|
||||||
|
onPress={onFindOnline}
|
||||||
|
disabled={actionBusy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="open-outline"
|
||||||
|
size={18}
|
||||||
|
color={primary ? colors.accentTextStrong : colors.textPrimary}
|
||||||
|
/>
|
||||||
|
<Text variant="body" color={primary ? colors.accentTextStrong : colors.textPrimary}>
|
||||||
|
Find online
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<View style={styles.footerActions}>
|
<View style={styles.footerActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -90,6 +115,12 @@ export function SignalResolutionPanel({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{onlineAction(true)}
|
||||||
|
{actionError ? (
|
||||||
|
<Text variant="label" color={colors.warning} style={styles.centerCopy}>
|
||||||
|
{actionError}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{footer}
|
{footer}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -132,6 +163,7 @@ export function SignalResolutionPanel({
|
|||||||
{actionError}
|
{actionError}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onlineAction()}
|
||||||
{footer}
|
{footer}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -194,6 +226,8 @@ export function SignalResolutionPanel({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{onlineAction()}
|
||||||
|
|
||||||
{actionError ? (
|
{actionError ? (
|
||||||
<Text variant="label" color={colors.warning} style={styles.centerCopy}>
|
<Text variant="label" color={colors.warning} style={styles.centerCopy}>
|
||||||
{actionError}
|
{actionError}
|
||||||
@@ -264,6 +298,23 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
|
onlineButton: {
|
||||||
|
minHeight: 46,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
},
|
||||||
|
onlineButtonPrimary: {
|
||||||
|
backgroundColor: colors.accent,
|
||||||
|
},
|
||||||
|
onlineButtonSecondary: {
|
||||||
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderColor: colors.glassBorder,
|
||||||
|
backgroundColor: colors.bgSecondary,
|
||||||
|
},
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user