mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
astra signal initial commit
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user