diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx index 3877779..ae4df4e 100644 --- a/src/app/signal/scan.tsx +++ b/src/app/signal/scan.tsx @@ -1,5 +1,5 @@ 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 * as DocumentPicker from 'expo-document-picker'; import { Ionicons } from '@expo/vector-icons'; @@ -17,6 +17,7 @@ import { import { decodeSignalFromUri } from '@/audio/signalDecodeImage'; import { matchSignalToLibrary } from '@/audio/signalLocalMatch'; import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; +import { encodeSignalWebUrl } from '@/audio/signalShare'; import { enqueueEnd, playTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { playHaptic } from '@/lib/haptics'; @@ -26,7 +27,7 @@ import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import type { SignalPayload } from '@boof2015/astra-signal'; -const MIN_READING_MS = 600; +const MIN_READING_MS = 300; const FAILURE_RETURN_MS = 480; 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 resultContent = result ? ( void playMatchedTrack(track)} onQueue={(track) => void queueMatchedTrack(track)} + onFindOnline={() => void findOnline(result)} onScanAnother={scanAnother} onDone={() => router.back()} /> diff --git a/src/audio/signalDecodeImage.ts b/src/audio/signalDecodeImage.ts index 62329e3..5682439 100644 --- a/src/audio/signalDecodeImage.ts +++ b/src/audio/signalDecodeImage.ts @@ -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 { decodeSignalImage, @@ -66,8 +66,8 @@ export async function decodeSignalFromUri( uri: string, options: DecodeSignalFromUriOptions = {} ): Promise { - const b64 = await readAsStringAsync(uri, { encoding: EncodingType.Base64 }); - const encoded = Skia.Data.fromBase64(b64); + const bytes = await new File(uri).bytes(); + const encoded = Skia.Data.fromBytes(bytes); const source = Skia.Image.MakeImageFromEncoded(encoded); encoded.dispose(); if (!source) throw new Error('Could not read that image.'); diff --git a/src/audio/signalShare.test.mts b/src/audio/signalShare.test.mts index 884669c..34506bb 100644 --- a/src/audio/signalShare.test.mts +++ b/src/audio/signalShare.test.mts @@ -4,6 +4,7 @@ import type { Track } from '../types/audio.ts'; import { SIGNAL_LINK_PREFIX, decodeTrackSignalLink, + encodeSignalWebUrl, encodeTrackSignalLink, signalInputFromTrack, 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', () => { assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/); assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`)); diff --git a/src/audio/signalShare.ts b/src/audio/signalShare.ts index d8f031c..d525b9a 100644 --- a/src/audio/signalShare.ts +++ b/src/audio/signalShare.ts @@ -11,6 +11,8 @@ import type { Track } from '../types/audio'; export { SIGNAL_LINK_PREFIX }; +export const SIGNAL_WEB_URL = 'https://astramusic.dev/signal/'; + /** Convert local track metadata into the database-free v3 Signal input. */ export function signalInputFromTrack(track: Track): SignalInput { return { @@ -30,6 +32,11 @@ export function encodeTrackSignalLink(track: Track): string { 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 { return decodeLibrarySignalLink(value); } diff --git a/src/components/signal/SignalResolutionPanel.tsx b/src/components/signal/SignalResolutionPanel.tsx index abf8a6c..295eab4 100644 --- a/src/components/signal/SignalResolutionPanel.tsx +++ b/src/components/signal/SignalResolutionPanel.tsx @@ -19,6 +19,7 @@ interface SignalResolutionPanelProps { actionError: string | null; onPlay: (track: DbTrack) => void; onQueue: (track: DbTrack) => void; + onFindOnline: () => void; onScanAnother: () => void; onDone: () => void; } @@ -30,6 +31,7 @@ export function SignalResolutionPanel({ actionError, onPlay, onQueue, + onFindOnline, onScanAnother, onDone, }: SignalResolutionPanelProps) { @@ -38,6 +40,29 @@ export function SignalResolutionPanel({ const ripple = useRipple(); const actionBusy = actionState === 'playing' || actionState === 'queueing'; + const onlineAction = (primary = false) => ( + + + + Find online + + + ); + const footer = ( + {onlineAction(true)} + {actionError ? ( + + {actionError} + + ) : null} {footer} ); @@ -132,6 +163,7 @@ export function SignalResolutionPanel({ {actionError} ) : null} + {onlineAction()} {footer} ); @@ -194,6 +226,8 @@ export function SignalResolutionPanel({ + {onlineAction()} + {actionError ? ( {actionError} @@ -264,6 +298,23 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', 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: { minHeight: 48, flex: 1,