astra signal initial commit

This commit is contained in:
Boof2015
2026-07-15 00:55:44 -04:00
parent 36fadf9925
commit ae3a49cf68
16 changed files with 1101 additions and 0 deletions
@@ -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.
+98
View File
@@ -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<SignalCodeHandle, SignalCodeProps>(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 (
<Canvas style={{ width, height }}>
<Rect x={0} y={0} width={width} height={height} color={background} />
<Path path={envelope} color={foreground} />
</Canvas>
);
});
@@ -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 (
<View style={styles.card}>
<View style={styles.badge}>
<Ionicons name="pulse" size={18} color={colors.accentTextStrong} />
<Text variant="label" color={colors.accentTextStrong}>
Signal found
</Text>
</View>
<Text variant="title" style={styles.title}>
{title || 'Unknown title'}
</Text>
<Text variant="body" color={colors.textSecondary}>
{artist || 'Unknown artist'}
</Text>
{payload.durationSec > 0 ? (
<View style={styles.metaRow}>
<Ionicons name="time-outline" size={15} color={colors.textTertiary} />
<Text variant="mono" color={colors.textTertiary}>
{formatDuration(payload.durationSec)}
</Text>
</View>
) : null}
</View>
);
}
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,
},
}));