From ae3a49cf68eb59a2bb7411b591837152de718d76 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:55:44 -0400 Subject: [PATCH] astra signal initial commit --- package-lock.json | 7 + package.json | 2 + src/app/+native-intent.ts | 4 + src/app/signal/import.tsx | 86 ++++++ src/app/signal/index.tsx | 181 +++++++++++++ src/app/signal/scan.tsx | 284 ++++++++++++++++++++ src/audio/signalDecodeImage.ts | 104 +++++++ src/audio/signalScanGeometry.test.mts | 37 +++ src/audio/signalScanGeometry.ts | 53 ++++ src/audio/signalShare.test.mts | 73 +++++ src/audio/signalShare.ts | 35 +++ src/audio/signalShareIntent.test.mts | 29 ++ src/audio/signalShareIntent.ts | 21 ++ src/components/player/NowPlayingOverlay.tsx | 12 + src/components/signal/SignalCode.tsx | 98 +++++++ src/components/signal/SignalResultCard.tsx | 75 ++++++ 16 files changed, 1101 insertions(+) create mode 100644 src/app/signal/import.tsx create mode 100644 src/app/signal/index.tsx create mode 100644 src/app/signal/scan.tsx create mode 100644 src/audio/signalDecodeImage.ts create mode 100644 src/audio/signalScanGeometry.test.mts create mode 100644 src/audio/signalScanGeometry.ts create mode 100644 src/audio/signalShare.test.mts create mode 100644 src/audio/signalShare.ts create mode 100644 src/audio/signalShareIntent.test.mts create mode 100644 src/audio/signalShareIntent.ts create mode 100644 src/components/signal/SignalCode.tsx create mode 100644 src/components/signal/SignalResultCard.tsx diff --git a/package-lock.json b/package-lock.json index 70e2ff2..642dac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@boof2015/astra-signal": "^0.3.0", "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", @@ -1164,6 +1165,12 @@ "node": ">=6.9.0" } }, + "node_modules/@boof2015/astra-signal": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@boof2015/astra-signal/-/astra-signal-0.3.0.tgz", + "integrity": "sha512-hPzi9Qv7VUK7DE+GWxciAIKm7QSgt5szdy+vwtavP0KvQEt8H8AqHO81xAgEPuR5d5EeaeEDXCF86PNXVJ9n5w==", + "license": "MIT" + }, "node_modules/@boof2015/xlrc": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@boof2015/xlrc/-/xlrc-0.2.1.tgz", diff --git a/package.json b/package.json index 4725fdf..c69d9ec 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "main": "index.js", "version": "0.1.0", "dependencies": { + "@boof2015/astra-signal": "^0.3.0", "@boof2015/xlrc": "^0.2.1", "@expo-google-fonts/inter": "^0.4.2", "@expo-google-fonts/jetbrains-mono": "^0.4.1", @@ -72,6 +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: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", diff --git a/src/app/+native-intent.ts b/src/app/+native-intent.ts index 313a6e4..1540488 100644 --- a/src/app/+native-intent.ts +++ b/src/app/+native-intent.ts @@ -3,6 +3,7 @@ import { resolveNotificationClick, } from '@/audio/notificationIntent'; import { getEQPresetShareRedirectPath } from '@/audio/eqShareIntent'; +import { getSignalShareRedirectPath } from '@/audio/signalShareIntent'; type RedirectSystemPathEvent = { path: string; @@ -15,5 +16,8 @@ export async function redirectSystemPath({ path }: RedirectSystemPathEvent): Pro const eqShareRedirect = getEQPresetShareRedirectPath(path); if (eqShareRedirect) return eqShareRedirect; + const signalRedirect = getSignalShareRedirectPath(path); + if (signalRedirect) return signalRedirect; + return path; } diff --git a/src/app/signal/import.tsx b/src/app/signal/import.tsx new file mode 100644 index 0000000..aeb7c12 --- /dev/null +++ b/src/app/signal/import.tsx @@ -0,0 +1,86 @@ +import { useMemo } from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import { decodeTrackSignalLink, SIGNAL_LINK_PREFIX } from '@/audio/signalShare'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import type { SignalPayload } from '@boof2015/astra-signal'; + +export default function SignalImportScreen() { + const styles = useStyles(); + const ripple = useRipple(); + const colors = useColors(); + const router = useRouter(); + const { data } = useLocalSearchParams<{ data?: string }>(); + + const payload = useMemo(() => { + if (!data) return null; + try { + return decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}${data}`); + } catch { + return null; + } + }, [data]); + + const goToSignal = () => router.replace('/signal' as never); + + return ( + + + + + + Signal + + + + + {payload ? ( + + ) : ( + + + This link does not contain a valid Astra Signal. + + + Go to Signal + + + + )} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + header: { + marginTop: spacing.md, + marginBottom: spacing.lg, + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + errorCard: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + gap: spacing.md, + }, + primaryButton: { + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.accent, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, +})); diff --git a/src/app/signal/index.tsx b/src/app/signal/index.tsx new file mode 100644 index 0000000..9a037b2 --- /dev/null +++ b/src/app/signal/index.tsx @@ -0,0 +1,181 @@ +import { useMemo, useRef } from 'react'; +import { Pressable, Share, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRouter } from 'expo-router'; +import { cacheDirectory, EncodingType, writeAsStringAsync } from 'expo-file-system/legacy'; +import * as Sharing from 'expo-sharing'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { SignalCode, type SignalCodeHandle } from '@/components/signal/SignalCode'; +import { encodeTrackSignalLink, signalLayoutFromTrack } from '@/audio/signalShare'; +import { usePlayerStore } from '@/stores/playerStore'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; + +// Keep the device presentation identical to the canonical shared PNG. The +// near-white isolation field is part of the proven phone-to-phone geometry. +const CODE_FG = '#0b0b12'; +const CODE_BG = '#f4f4f6'; + +export default function SignalScreen() { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const router = useRouter(); + const track = usePlayerStore((s) => s.currentTrack); + const codeRef = useRef(null); + const { width: screenWidth } = useWindowDimensions(); + const layout = useMemo(() => (track ? signalLayoutFromTrack(track) : null), [track]); + const availableWidth = Math.max(1, screenWidth - spacing.lg * 4); + const targetWidth = layout?.tier === 'small' ? 280 : layout?.tier === 'medium' ? 340 : availableWidth; + const codeWidth = Math.min(targetWidth, availableWidth); + + const shareImage = async () => { + const base64 = codeRef.current?.snapshot(); + if (!base64 || !cacheDirectory) return; + const fileUri = `${cacheDirectory}astra-signal.png`; + await writeAsStringAsync(fileUri, base64, { encoding: EncodingType.Base64 }); + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(fileUri, { + mimeType: 'image/png', + dialogTitle: 'Share Astra Signal', + UTI: 'public.png', + }); + } + }; + + const shareLink = async () => { + if (!track) return; + await Share.share({ message: encodeTrackSignalLink(track) }); + }; + + return ( + + + router.back()} hitSlop={8}> + + + Back + + + router.navigate('/signal/scan' as never)} hitSlop={8}> + + + + + + Astra Signal + + + {!track || !layout ? ( + + + + Play a song to make its Signal. + + + ) : ( + + + + + + {track.title} + + + {track.artist} + + + + void shareImage()}> + + + Share Signal + + + void shareLink()}> + + Share link + + + + )} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + header: { + marginTop: spacing.md, + marginBottom: spacing.lg, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + scanBtn: { + padding: spacing.xs, + }, + heading: { + marginBottom: spacing.lg, + }, + empty: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.md, + }, + body: { + alignItems: 'center', + }, + codeCard: { + padding: spacing.lg, + borderRadius: radius.lg, + backgroundColor: CODE_BG, + marginBottom: spacing.lg, + overflow: 'hidden', + }, + trackTitle: { + marginTop: spacing.xs, + }, + actions: { + flexDirection: 'row', + gap: spacing.md, + marginTop: spacing.xl, + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.accent, + paddingHorizontal: spacing.lg, + justifyContent: 'center', + }, + secondaryButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.bgSecondary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + paddingHorizontal: spacing.lg, + justifyContent: 'center', + }, +})); diff --git a/src/app/signal/scan.tsx b/src/app/signal/scan.tsx new file mode 100644 index 0000000..dab36da --- /dev/null +++ b/src/app/signal/scan.tsx @@ -0,0 +1,284 @@ +import { useRef, useState } from 'react'; +import { ActivityIndicator, 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'; +import { useRouter } from 'expo-router'; +import { Screen } from '@/components/Screen'; +import { Text } from '@/components/Text'; +import { SignalResultCard } from '@/components/signal/SignalResultCard'; +import { decodeSignalFromUri } from '@/audio/signalDecodeImage'; +import { SIGNAL_SCAN_GUIDE } from '@/audio/signalScanGeometry'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import type { SignalPayload } from '@boof2015/astra-signal'; + +export default function SignalScanScreen() { + const styles = useStyles(); + const ripple = useRipple(); + const colors = useColors(); + const router = useRouter(); + const cameraRef = useRef(null); + const [permission, requestPermission] = useCameraPermissions(); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [previewSize, setPreviewSize] = useState({ width: 0, height: 0 }); + + const runDecode = async (uri: string, useGuideCrop = false) => { + setBusy(true); + setError(null); + try { + setResult(await decodeSignalFromUri(uri, useGuideCrop ? { previewSize } : undefined)); + } catch { + setError("Couldn't read Signal. Line the code up in the frame, hold steady, and try again."); + } finally { + setBusy(false); + } + }; + + const capture = async () => { + if (busy) return; + const photo = await cameraRef.current?.takePictureAsync({ quality: 1 }); + if (photo?.uri) await runDecode(photo.uri, true); + }; + + const pickImage = async () => { + if (busy) return; + const picked = await DocumentPicker.getDocumentAsync({ type: 'image/*', copyToCacheDirectory: true }); + const uri = picked.assets?.[0]?.uri; + if (uri) await runDecode(uri); + }; + + return ( + + + router.back()} hitSlop={8}> + + + Signal + + + + + + Scan the Signal + + + {!permission ? ( + + ) : !permission.granted ? ( + + + Camera access is needed to scan a Signal. + void requestPermission()}> + + Allow camera + + + void pickImage()}> + + Or pick a Signal image + + + + ) : ( + + setPreviewSize(event.nativeEvent.layout)} + /> + + + + + + + {busy ? ( + + + + Reading signal… + + + ) : null} + + void pickImage()} hitSlop={8}> + + + void capture()} hitSlop={8}> + + + + + + )} + + {error ? ( + + + + {error} + + + ) : null} + + {result ? ( + + + setResult(null)}> + + Scan another + + + + ) : null} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + header: { + marginTop: spacing.md, + marginBottom: spacing.lg, + }, + back: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + heading: { + marginBottom: spacing.lg, + }, + center: { + flex: 1, + }, + permissionCard: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + gap: spacing.md, + }, + primaryButton: { + minHeight: 44, + borderRadius: radius.sm, + backgroundColor: colors.accent, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, + linkButton: { + alignItems: 'center', + paddingVertical: spacing.sm, + }, + scannerFrame: { + flex: 1, + borderRadius: radius.md, + overflow: 'hidden', + backgroundColor: colors.bgSecondary, + marginBottom: spacing.lg, + }, + camera: { + flex: 1, + }, + scanGuide: { + position: 'absolute', + left: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`, + right: `${SIGNAL_SCAN_GUIDE.horizontalInset * 100}%`, + top: `${SIGNAL_SCAN_GUIDE.top * 100}%`, + aspectRatio: SIGNAL_SCAN_GUIDE.aspectRatio, + }, + guideCorner: { + position: 'absolute', + width: 30, + height: 22, + borderColor: colors.accent, + }, + guideTopLeft: { + left: 0, + top: 0, + borderLeftWidth: 3, + borderTopWidth: 3, + borderTopLeftRadius: radius.sm, + }, + guideTopRight: { + right: 0, + top: 0, + borderRightWidth: 3, + borderTopWidth: 3, + borderTopRightRadius: radius.sm, + }, + guideBottomLeft: { + left: 0, + bottom: 0, + borderLeftWidth: 3, + borderBottomWidth: 3, + borderBottomLeftRadius: radius.sm, + }, + guideBottomRight: { + right: 0, + bottom: 0, + borderRightWidth: 3, + borderBottomWidth: 3, + borderBottomRightRadius: radius.sm, + }, + busyOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + backgroundColor: 'rgba(0,0,0,0.35)', + }, + controls: { + position: 'absolute', + left: spacing.lg, + right: spacing.lg, + bottom: spacing.lg, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + iconButton: { + width: 48, + height: 48, + borderRadius: radius.pill, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgSecondary, + }, + shutter: { + width: 64, + height: 64, + borderRadius: radius.pill, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.accent, + }, + errorPanel: { + flexDirection: 'row', + gap: spacing.sm, + alignItems: 'center', + borderRadius: radius.md, + backgroundColor: colors.bgSecondary, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + padding: spacing.md, + marginBottom: spacing.md, + }, + errorCopy: { + flex: 1, + }, + resultWrap: { + gap: spacing.md, + marginBottom: spacing.lg, + }, +})); diff --git a/src/audio/signalDecodeImage.ts b/src/audio/signalDecodeImage.ts new file mode 100644 index 0000000..62329e3 --- /dev/null +++ b/src/audio/signalDecodeImage.ts @@ -0,0 +1,104 @@ +import { EncodingType, readAsStringAsync } from 'expo-file-system/legacy'; +import { AlphaType, ColorType, Skia, rect, type SkImage } from '@shopify/react-native-skia'; +import { + decodeSignalImage, + type SignalDecodeResult, + type SignalPayload, +} from '@boof2015/astra-signal'; +import { + signalGuideCaptureSourceRect, + type SignalImageRect, + type SignalImageSize, +} from './signalScanGeometry'; + +const MAX_DIM = 2048; + +export interface DecodeSignalFromUriOptions { + /** Camera preview size used to map the on-screen guide into the full photo. */ + previewSize?: SignalImageSize; +} + +function fullRect(source: SkImage): SignalImageRect { + return { x: 0, y: 0, width: source.width(), height: source.height() }; +} + +function decodeRect(source: SkImage, crop: SignalImageRect): SignalDecodeResult { + const scale = Math.min(1, MAX_DIM / Math.max(crop.width, crop.height)); + const width = Math.max(1, Math.round(crop.width * scale)); + const height = Math.max(1, Math.round(crop.height * scale)); + const surface = Skia.Surface.MakeOffscreen(width, height); + if (!surface) throw new Error('Could not process that image.'); + const paint = Skia.Paint(); + const canvas = surface.getCanvas(); + canvas.drawImageRect( + source, + rect(crop.x, crop.y, crop.width, crop.height), + rect(0, 0, width, height), + paint + ); + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + const pixels = snapshot.readPixels(0, 0, { + width, + height, + colorType: ColorType.RGBA_8888, + alphaType: AlphaType.Unpremul, + }); + paint.dispose(); + snapshot.dispose(); + surface.dispose(); + if (!pixels) throw new Error('Could not read image pixels.'); + return decodeSignalImage({ data: pixels, width, height }); +} + +function logDiagnostics(result: SignalDecodeResult, source: 'guide' | 'full'): void { + if (!__DEV__) return; + console.debug('[Astra Signal] decoded image', { + source, + tier: result.tier, + correctedBytes: result.correctedBytes, + erasedBytes: result.erasedBytes, + confidence: result.confidence, + }); +} + +export async function decodeSignalFromUri( + uri: string, + options: DecodeSignalFromUriOptions = {} +): Promise { + const b64 = await readAsStringAsync(uri, { encoding: EncodingType.Base64 }); + const encoded = Skia.Data.fromBase64(b64); + const source = Skia.Image.MakeImageFromEncoded(encoded); + encoded.dispose(); + if (!source) throw new Error('Could not read that image.'); + + try { + const crop = options.previewSize + ? signalGuideCaptureSourceRect( + { width: source.width(), height: source.height() }, + options.previewSize + ) + : null; + if (crop) { + try { + const result = decodeRect(source, crop); + logDiagnostics(result, 'guide'); + return result.payload; + } catch (error) { + if (__DEV__) console.debug('[Astra Signal] guide decode failed', error); + // The guide crop is a fast, high-resolution first attempt. Full-image + // search below handles framing mismatch and images picked from storage. + } + } + try { + const result = decodeRect(source, fullRect(source)); + logDiagnostics(result, 'full'); + return result.payload; + } catch (error) { + if (__DEV__) console.debug('[Astra Signal] full-image decode failed', error); + throw error; + } + } finally { + source.dispose(); + } +} diff --git a/src/audio/signalScanGeometry.test.mts b/src/audio/signalScanGeometry.test.mts new file mode 100644 index 0000000..4697a0c --- /dev/null +++ b/src/audio/signalScanGeometry.test.mts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + SIGNAL_SCAN_GUIDE, + signalGuideCapturePreviewRect, + signalGuideCaptureSourceRect, +} from './signalScanGeometry.ts'; + +test('uses the padded small-tier presentation geometry for the visible guide', () => { + assert.equal(SIGNAL_SCAN_GUIDE.aspectRatio, 2.65); +}); + +test('the high-resolution crop spans the preview but keeps allocation height bounded', () => { + const preview = { width: 360, height: 640 }; + const crop = signalGuideCapturePreviewRect(preview); + assert.ok(crop); + const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2); + const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio; + assert.equal(crop.x, 0); + assert.equal(crop.width, preview.width); + assert.ok(crop.width > guideWidth); + assert.ok(crop.height > guideHeight); + assert.ok(crop.height < guideHeight * 1.5); +}); + +test('maps the expanded preview band into a portrait camera capture', () => { + const crop = signalGuideCaptureSourceRect( + { width: 3000, height: 4000 }, + { width: 360, height: 640 } + ); + assert.ok(crop); + assert.ok(crop.x > 0); + assert.ok(crop.x + crop.width < 3000); + assert.ok(crop.width > 2200); + assert.ok(crop.height > 950); + assert.ok(crop.height < 1200); +}); diff --git a/src/audio/signalScanGeometry.ts b/src/audio/signalScanGeometry.ts new file mode 100644 index 0000000..1f3d89e --- /dev/null +++ b/src/audio/signalScanGeometry.ts @@ -0,0 +1,53 @@ +export interface SignalImageSize { + width: number; + height: number; +} + +export interface SignalImageRect extends SignalImageSize { + x: number; + y: number; +} + +// The visible frame describes the common small tier plus the real isolation +// padding used by the mobile presentation, rather than only the black envelope. +export const SIGNAL_SCAN_GUIDE = { + horizontalInset: 0.06, + top: 0.32, + aspectRatio: 2.65, +} as const; + +const OPTICAL_SEARCH_ASPECT = 228 / 40; + +/** + * The visible guide is not a crop boundary. Capture the entire preview width + * while keeping the vertical band bounded. A large first-pass bitmap can leave + * too much allocation pressure for the full-image fallback on a phone. + */ +export function signalGuideCapturePreviewRect(preview: SignalImageSize): SignalImageRect | null { + if (preview.width <= 0 || preview.height <= 0) return null; + const guideWidth = preview.width * (1 - SIGNAL_SCAN_GUIDE.horizontalInset * 2); + const guideHeight = guideWidth / SIGNAL_SCAN_GUIDE.aspectRatio; + const guideCenterY = preview.height * SIGNAL_SCAN_GUIDE.top + guideHeight / 2; + const searchHeight = (guideWidth / OPTICAL_SEARCH_ASPECT) * 3; + const y0 = Math.max(0, guideCenterY - searchHeight / 2); + const y1 = Math.min(preview.height, guideCenterY + searchHeight / 2); + return { x: 0, y: y0, width: preview.width, height: y1 - y0 }; +} + +/** Map the expanded visible-preview search band into the captured photo. */ +export function signalGuideCaptureSourceRect( + source: SignalImageSize, + preview: SignalImageSize +): SignalImageRect | null { + const capture = signalGuideCapturePreviewRect(preview); + if (!capture || source.width <= 0 || source.height <= 0) return null; + const coverScale = Math.max(preview.width / source.width, preview.height / source.height); + const overflowX = (source.width * coverScale - preview.width) / 2; + const overflowY = (source.height * coverScale - preview.height) / 2; + const x0 = Math.max(0, (capture.x + overflowX) / coverScale); + const y0 = Math.max(0, (capture.y + overflowY) / coverScale); + const x1 = Math.min(source.width, (capture.x + capture.width + overflowX) / coverScale); + const y1 = Math.min(source.height, (capture.y + capture.height + overflowY) / coverScale); + if (x1 - x0 < 2 || y1 - y0 < 2) return null; + return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }; +} diff --git a/src/audio/signalShare.test.mts b/src/audio/signalShare.test.mts new file mode 100644 index 0000000..884669c --- /dev/null +++ b/src/audio/signalShare.test.mts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Track } from '../types/audio.ts'; +import { + SIGNAL_LINK_PREFIX, + decodeTrackSignalLink, + encodeTrackSignalLink, + signalInputFromTrack, + signalLayoutFromTrack, +} from './signalShare.ts'; + +function track(overrides: Partial = {}): Track { + return { + id: 'signal-test', + path: '/music/replay.flac', + title: 'Replay', + artist: 'ナナツカゼ', + album: 'Signal Tests', + duration: 213.6, + format: 'flac', + ...overrides, + }; +} + +test('converts a Track into rounded, database-free Signal metadata', () => { + assert.deepEqual(signalInputFromTrack(track()), { + artist: 'ナナツカゼ', + title: 'Replay', + durationSec: 214, + }); + assert.equal(signalInputFromTrack(track({ duration: Number.NaN })).durationSec, 0); +}); + +test('creates a v3 connected layout and round-trips a Unicode link', () => { + const source = track(); + const layout = signalLayoutFromTrack(source); + assert.equal(layout.version, 3); + assert.equal(layout.columns.length, layout.dataColumns + 8); + + const link = encodeTrackSignalLink(source); + assert.ok(link.startsWith(SIGNAL_LINK_PREFIX)); + assert.deepEqual(decodeTrackSignalLink(link), { + version: 3, + type: 'metadata', + artist: 'ナナツカゼ', + title: 'Replay', + durationSec: 214, + }); +}); + +test('preserves ASCII case and punctuation in track metadata', () => { + const source = track({ artist: 'N!GHT', title: '#iwannadance', duration: 222 }); + const layout = signalLayoutFromTrack(source); + assert.equal(layout.payload.artist, 'N!GHT'); + assert.equal(layout.payload.title, '#iwannadance'); + assert.deepEqual(decodeTrackSignalLink(encodeTrackSignalLink(source)), { + version: 3, + type: 'metadata', + artist: 'N!GHT', + title: '#iwannadance', + durationSec: 222, + }); +}); + +test('rejects non-v3, malformed, and CRC-corrupted links', () => { + assert.throws(() => decodeTrackSignalLink('astra:signal:v2:AAAA'), /v3 link/); + assert.throws(() => decodeTrackSignalLink(`${SIGNAL_LINK_PREFIX}%`)); + const valid = encodeTrackSignalLink(track()); + const index = SIGNAL_LINK_PREFIX.length + 5; + const replacement = valid[index] === 'A' ? 'B' : 'A'; + const corrupted = `${valid.slice(0, index)}${replacement}${valid.slice(index + 1)}`; + assert.throws(() => decodeTrackSignalLink(corrupted)); +}); diff --git a/src/audio/signalShare.ts b/src/audio/signalShare.ts new file mode 100644 index 0000000..d8f031c --- /dev/null +++ b/src/audio/signalShare.ts @@ -0,0 +1,35 @@ +import { + SIGNAL_LINK_PREFIX, + decodeSignalLink as decodeLibrarySignalLink, + encodeSignal, + encodeSignalLink as encodeLibrarySignalLink, + type SignalInput, + type SignalLayout, + type SignalPayload, +} from '@boof2015/astra-signal'; +import type { Track } from '../types/audio'; + +export { SIGNAL_LINK_PREFIX }; + +/** Convert local track metadata into the database-free v3 Signal input. */ +export function signalInputFromTrack(track: Track): SignalInput { + return { + artist: track.artist, + title: track.title, + durationSec: Number.isFinite(track.duration) ? Math.round(track.duration) : 0, + }; +} + +/** Build the ECC-protected optical layout rendered by SignalCode. */ +export function signalLayoutFromTrack(track: Track): SignalLayout { + return encodeSignal(signalInputFromTrack(track)); +} + +/** Build the compact v3 link frame. Links carry CRC, but no visual padding or ECC. */ +export function encodeTrackSignalLink(track: Track): string { + return encodeLibrarySignalLink(signalInputFromTrack(track)); +} + +export function decodeTrackSignalLink(value: string): SignalPayload { + return decodeLibrarySignalLink(value); +} diff --git a/src/audio/signalShareIntent.test.mts b/src/audio/signalShareIntent.test.mts new file mode 100644 index 0000000..f15565e --- /dev/null +++ b/src/audio/signalShareIntent.test.mts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Track } from '../types/audio.ts'; +import { SIGNAL_LINK_PREFIX, encodeTrackSignalLink } from './signalShare.ts'; +import { getSignalShareRedirectPath } from './signalShareIntent.ts'; + +const source: Track = { + id: 'intent-test', + path: '/music/replay.flac', + title: 'Replay', + artist: 'ナナツカゼ', + album: 'Signal Tests', + duration: 214, + format: 'flac', +}; + +test('routes native and percent-encoded Astra Signal v3 links', () => { + const link = encodeTrackSignalLink(source); + const payload = link.slice(SIGNAL_LINK_PREFIX.length); + assert.equal(getSignalShareRedirectPath(link), `/signal/import?data=${payload}`); + assert.equal(getSignalShareRedirectPath(encodeURIComponent(link)), `/signal/import?data=${payload}`); + assert.equal(getSignalShareRedirectPath(`astra://signal:v3:${payload}`), `/signal/import?data=${payload}`); +}); + +test('ignores unrelated, v2, and empty Signal intents', () => { + assert.equal(getSignalShareRedirectPath('https://example.com'), null); + assert.equal(getSignalShareRedirectPath('astra:signal:v2:AAAA'), null); + assert.equal(getSignalShareRedirectPath('astra:signal:v3:'), null); +}); diff --git a/src/audio/signalShareIntent.ts b/src/audio/signalShareIntent.ts new file mode 100644 index 0000000..c926d48 --- /dev/null +++ b/src/audio/signalShareIntent.ts @@ -0,0 +1,21 @@ +import { SIGNAL_LINK_PREFIX } from './signalShare.ts'; + +// Match the scheme-less marker because Android/Expo can normalize opaque +// `astra:` URIs before redirectSystemPath receives them. +const SIGNAL_MARKER = SIGNAL_LINK_PREFIX.replace(/^astra:/, ''); + +/** Return the import route for an Astra Signal v3 deep link, or null. */ +export function getSignalShareRedirectPath(path: string): string | null { + let candidate = path.trim(); + try { + candidate = decodeURIComponent(candidate); + } catch { + // Malformed percent-encoding cannot be a valid frame, but matching the raw + // string keeps this router helper side-effect free. + } + const idx = candidate.indexOf(SIGNAL_MARKER); + if (idx === -1) return null; + const payload = candidate.slice(idx + SIGNAL_MARKER.length).match(/^[A-Za-z0-9_-]+/)?.[0]; + if (!payload) return null; + return `/signal/import?data=${payload}`; +} diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 52efdad..037ca61 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -363,6 +363,18 @@ export function NowPlayingOverlay() { }, }); } + if (!isDesktopTarget) { + menuItems.push({ + key: 'share-signal', + label: 'Share as Signal', + icon: 'pulse-outline', + onPress: () => { + closeMenu(); + dismissSheet(); + router.navigate('/signal' as never); + }, + }); + } // The overlay stays mounted; open/close is this one shared value sliding the // sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes. diff --git a/src/components/signal/SignalCode.tsx b/src/components/signal/SignalCode.tsx new file mode 100644 index 0000000..c2711a0 --- /dev/null +++ b/src/components/signal/SignalCode.tsx @@ -0,0 +1,98 @@ +import { forwardRef, useImperativeHandle, useMemo } from 'react'; +import { AlphaType, Canvas, ColorType, Path, Rect, Skia } from '@shopify/react-native-skia'; +import { + SIGNAL_SPEC, + levelHeightModules, + rasterizeSignal, + type SignalLayout, +} from '@boof2015/astra-signal'; + +export interface SignalCodeHandle { + /** Canonical six-pixels-per-module PNG as base64, independent of display size. */ + snapshot: () => string | null; +} + +interface SignalCodeProps { + layout: SignalLayout; + width: number; + foreground: string; + background: string; + exportForeground?: string; + exportBackground?: string; +} + +const G = SIGNAL_SPEC.geom; + +function rgb(hex: string): [number, number, number] { + const value = hex.replace(/^#/, ''); + if (!/^[0-9a-fA-F]{6}$/.test(value)) throw new Error('Signal colors must be six-digit hex values'); + return [ + Number.parseInt(value.slice(0, 2), 16), + Number.parseInt(value.slice(2, 4), 16), + Number.parseInt(value.slice(4, 6), 16), + ]; +} + +/** Render the v3 connected upper/lower spectrum envelope directly from SignalLayout. */ +export const SignalCode = forwardRef(function SignalCode( + { layout, width, foreground, background, exportForeground = foreground, exportBackground = background }, + ref +) { + const scale = width / layout.widthModules; + const height = layout.heightModules * scale; + + useImperativeHandle( + ref, + () => ({ + snapshot: () => { + const raster = rasterizeSignal(layout, { + scale: 6, + foreground: rgb(exportForeground), + background: rgb(exportBackground), + }); + const bytes = new Uint8Array(raster.data.length); + bytes.set(raster.data); + const data = Skia.Data.fromBytes(bytes); + const image = Skia.Image.MakeImage( + { + width: raster.width, + height: raster.height, + colorType: ColorType.RGBA_8888, + alphaType: AlphaType.Unpremul, + }, + data, + raster.width * 4 + ); + data.dispose(); + if (!image) return null; + try { + return image.encodeToBase64(); + } finally { + image.dispose(); + } + }, + }), + [exportBackground, exportForeground, layout] + ); + + const envelope = useMemo(() => { + const path = Skia.Path.Make(); + const centerY = (G.quietModules + G.halfHeightModules) * scale; + for (let index = 0; index < layout.columns.length; index += 1) { + const column = layout.columns[index]; + if (!column) continue; + const upper = levelHeightModules(column.upperLevel) * scale; + const lower = levelHeightModules(column.lowerLevel) * scale; + const x = (G.quietModules + index * G.columnPitchModules) * scale; + path.addRect(Skia.XYWHRect(x, centerY - upper, G.columnPitchModules * scale, upper + lower)); + } + return path; + }, [layout, scale]); + + return ( + + + + + ); +}); diff --git a/src/components/signal/SignalResultCard.tsx b/src/components/signal/SignalResultCard.tsx new file mode 100644 index 0000000..191bc28 --- /dev/null +++ b/src/components/signal/SignalResultCard.tsx @@ -0,0 +1,75 @@ +import { StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { formatDuration } from '@/lib/format'; +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. + */ +export function SignalResultCard({ payload }: { payload: SignalPayload }) { + const styles = useStyles(); + const colors = useColors(); + const title = payload.title.trim(); + const artist = payload.artist.trim(); + + return ( + + + + + Signal found + + + + {title || 'Unknown title'} + + + {artist || 'Unknown artist'} + + {payload.durationSec > 0 ? ( + + + + {formatDuration(payload.durationSec)} + + + ) : null} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + card: { + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + padding: spacing.lg, + gap: spacing.xs, + }, + badge: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + alignSelf: 'flex-start', + paddingHorizontal: spacing.sm, + paddingVertical: 4, + borderRadius: radius.pill, + backgroundColor: colors.accentGlow, + marginBottom: spacing.sm, + }, + title: { + marginTop: spacing.xs, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + }, +}));