mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
feat: Implement RSSI service for iOS and Web platforms
- Added IosRssiService to handle synthetic RSSI data for iOS. - Created WebRssiService to simulate RSSI scanning on the web. - Defined shared types for WifiNetwork and RssiService in rssi.service.ts. - Introduced simulation service to generate synthetic sensing data. - Implemented WebSocket service for real-time data handling with reconnection logic. - Established Zustand stores for managing application state related to MAT and pose data. - Developed theme context and utility functions for consistent styling and formatting. - Added type definitions for various application entities including API responses and sensing data. - Created utility functions for color mapping and URL validation. - Configured TypeScript settings for the mobile application.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { LayoutChangeEvent, StyleSheet } from 'react-native';
|
||||
import type { RefObject } from 'react';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import GAUSSIAN_SPLATS_HTML from '@/assets/webview/gaussian-splats.html';
|
||||
|
||||
type GaussianSplatWebViewProps = {
|
||||
onMessage: (event: WebViewMessageEvent) => void;
|
||||
onError: () => void;
|
||||
webViewRef: RefObject<WebView | null>;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
};
|
||||
|
||||
export const GaussianSplatWebView = ({
|
||||
onMessage,
|
||||
onError,
|
||||
webViewRef,
|
||||
onLayout,
|
||||
}: GaussianSplatWebViewProps) => {
|
||||
const html = typeof GAUSSIAN_SPLATS_HTML === 'string' ? GAUSSIAN_SPLATS_HTML : '';
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html }}
|
||||
originWhitelist={['*']}
|
||||
allowFileAccess={false}
|
||||
javaScriptEnabled
|
||||
onMessage={onMessage}
|
||||
onError={onError}
|
||||
onLayout={onLayout}
|
||||
style={styles.webView}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webView: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0A0E1A',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import * as THREE from 'three';
|
||||
import type { SensingFrame } from '@/types/sensing';
|
||||
|
||||
type GaussianSplatWebViewWebProps = {
|
||||
onReady: () => void;
|
||||
onFps: (fps: number) => void;
|
||||
onError: (msg: string) => void;
|
||||
frame: SensingFrame | null;
|
||||
};
|
||||
|
||||
const BONES: [number, number][] = [
|
||||
[0,1],[0,2],[1,3],[2,4],[5,6],[5,7],[7,9],[6,8],[8,10],
|
||||
[5,11],[6,12],[11,12],[11,13],[13,15],[12,14],[14,16],
|
||||
];
|
||||
|
||||
export const GaussianSplatWebViewWeb = ({ onReady, onFps, onError, frame }: GaussianSplatWebViewWebProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<{
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
joints: THREE.Mesh[];
|
||||
boneLines: { line: THREE.Line; a: number; b: number }[];
|
||||
ring: THREE.Mesh;
|
||||
particleGeo: THREE.BufferGeometry;
|
||||
pointLight: THREE.PointLight;
|
||||
animId: number;
|
||||
cameraAngle: number;
|
||||
cameraRadius: number;
|
||||
cameraY: number;
|
||||
isDragging: boolean;
|
||||
frameCount: number;
|
||||
lastFpsTime: number;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<SensingFrame | null>(null);
|
||||
|
||||
// Keep frame ref current without re-running effect
|
||||
frameRef.current = frame;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
const s = sceneRef.current;
|
||||
if (!s) return;
|
||||
cancelAnimationFrame(s.animId);
|
||||
s.renderer.dispose();
|
||||
s.scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry.dispose();
|
||||
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
|
||||
else obj.material.dispose();
|
||||
}
|
||||
});
|
||||
sceneRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const W = () => container.clientWidth || window.innerWidth;
|
||||
const H = () => container.clientHeight || window.innerHeight;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(W(), H());
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x0a0e1a);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0e1a);
|
||||
scene.fog = new THREE.FogExp2(0x0a0e1a, 0.008);
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(60, W() / H(), 0.1, 500);
|
||||
camera.position.set(0, 2, 6);
|
||||
camera.lookAt(0, 1, 0);
|
||||
|
||||
// Grid
|
||||
const grid = new THREE.GridHelper(20, 40, 0x1a3a4a, 0x0d1f2a);
|
||||
scene.add(grid);
|
||||
|
||||
// Lights
|
||||
scene.add(new THREE.AmbientLight(0x32b8c6, 0.3));
|
||||
const pointLight = new THREE.PointLight(0x32b8c6, 1.5, 20);
|
||||
pointLight.position.set(0, 4, 0);
|
||||
scene.add(pointLight);
|
||||
|
||||
// Skeleton joints (17 COCO keypoints)
|
||||
const jointGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
||||
const joints: THREE.Mesh[] = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x32b8c6,
|
||||
emissive: 0x32b8c6,
|
||||
emissiveIntensity: 0.6,
|
||||
});
|
||||
const m = new THREE.Mesh(jointGeo, mat);
|
||||
m.visible = false;
|
||||
scene.add(m);
|
||||
joints.push(m);
|
||||
}
|
||||
|
||||
// Bone lines
|
||||
const boneMat = new THREE.LineBasicMaterial({
|
||||
color: 0x32b8c6,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
const boneLines = BONES.map(([a, b]) => {
|
||||
const g = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(),
|
||||
]);
|
||||
const l = new THREE.Line(g, boneMat);
|
||||
l.visible = false;
|
||||
scene.add(l);
|
||||
return { line: l, a, b };
|
||||
});
|
||||
|
||||
// Particle field
|
||||
const N = 500;
|
||||
const particleGeo = new THREE.BufferGeometry();
|
||||
const pPos = new Float32Array(N * 3);
|
||||
for (let i = 0; i < N; i++) {
|
||||
pPos[i * 3] = (Math.random() - 0.5) * 16;
|
||||
pPos[i * 3 + 1] = Math.random() * 4;
|
||||
pPos[i * 3 + 2] = (Math.random() - 0.5) * 16;
|
||||
}
|
||||
particleGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
|
||||
const pMat = new THREE.PointsMaterial({
|
||||
color: 0x32b8c6,
|
||||
size: 0.04,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
});
|
||||
scene.add(new THREE.Points(particleGeo, pMat));
|
||||
|
||||
// Signal ring
|
||||
const ringGeo = new THREE.TorusGeometry(2, 0.02, 8, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x32b8c6,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.y = 0.01;
|
||||
scene.add(ring);
|
||||
|
||||
// State
|
||||
const state = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
joints,
|
||||
boneLines,
|
||||
ring,
|
||||
particleGeo,
|
||||
pointLight,
|
||||
animId: 0,
|
||||
cameraAngle: 0,
|
||||
cameraRadius: 6,
|
||||
cameraY: 2,
|
||||
isDragging: false,
|
||||
frameCount: 0,
|
||||
lastFpsTime: performance.now(),
|
||||
};
|
||||
sceneRef.current = state;
|
||||
|
||||
// Mouse interaction
|
||||
const canvas = renderer.domElement;
|
||||
const onMouseDown = () => { state.isDragging = true; };
|
||||
const onMouseUp = () => { state.isDragging = false; };
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (state.isDragging) {
|
||||
state.cameraAngle += e.movementX * 0.01;
|
||||
state.cameraY = Math.max(0.5, Math.min(5, state.cameraY - e.movementY * 0.01));
|
||||
}
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
state.cameraRadius = Math.max(2, Math.min(15, state.cameraRadius + e.deltaY * 0.005));
|
||||
};
|
||||
canvas.addEventListener('mousedown', onMouseDown);
|
||||
canvas.addEventListener('mouseup', onMouseUp);
|
||||
canvas.addEventListener('mousemove', onMouseMove);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: true });
|
||||
|
||||
// Resize
|
||||
const onResize = () => {
|
||||
camera.aspect = W() / H();
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(W(), H());
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
state.animId = requestAnimationFrame(animate);
|
||||
const t = performance.now() * 0.001;
|
||||
|
||||
// Camera orbit
|
||||
if (!state.isDragging) state.cameraAngle += 0.002;
|
||||
camera.position.set(
|
||||
Math.sin(state.cameraAngle) * state.cameraRadius,
|
||||
state.cameraY,
|
||||
Math.cos(state.cameraAngle) * state.cameraRadius,
|
||||
);
|
||||
camera.lookAt(0, 1, 0);
|
||||
|
||||
// Animate ring
|
||||
ring.material.opacity = 0.15 + Math.sin(t * 2) * 0.1;
|
||||
const scale = 1 + Math.sin(t) * 0.1;
|
||||
ring.scale.set(scale, scale, 1);
|
||||
|
||||
// Animate particles
|
||||
const pp = particleGeo.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < N; i++) {
|
||||
(pp.array as Float32Array)[i * 3 + 1] += Math.sin(t + i) * 0.001;
|
||||
}
|
||||
pp.needsUpdate = true;
|
||||
|
||||
// Update skeleton from frame data
|
||||
const currentFrame = frameRef.current;
|
||||
if (currentFrame) {
|
||||
const persons = (currentFrame as any).persons || [];
|
||||
if (persons.length > 0) {
|
||||
const kps = persons[0].keypoints || [];
|
||||
kps.forEach((kp: any, i: number) => {
|
||||
if (i < 17 && joints[i]) {
|
||||
joints[i].position.set(
|
||||
(kp.x - 0.5) * 4,
|
||||
(1 - kp.y) * 3,
|
||||
(kp.z || 0) * 2,
|
||||
);
|
||||
joints[i].visible = kp.confidence > 0.3;
|
||||
(joints[i].material as THREE.MeshStandardMaterial).emissiveIntensity =
|
||||
0.3 + kp.confidence * 0.7;
|
||||
}
|
||||
});
|
||||
boneLines.forEach(({ line, a, b }) => {
|
||||
if (joints[a].visible && joints[b].visible) {
|
||||
const pos = line.geometry.attributes.position as THREE.BufferAttribute;
|
||||
pos.setXYZ(0, joints[a].position.x, joints[a].position.y, joints[a].position.z);
|
||||
pos.setXYZ(1, joints[b].position.x, joints[b].position.y, joints[b].position.z);
|
||||
pos.needsUpdate = true;
|
||||
line.visible = true;
|
||||
} else {
|
||||
line.visible = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
joints.forEach((j) => { j.visible = false; });
|
||||
boneLines.forEach((bl) => { bl.line.visible = false; });
|
||||
}
|
||||
|
||||
// Adjust light from RSSI
|
||||
const features = (currentFrame as any).features;
|
||||
if (features) {
|
||||
const rssi = features.mean_rssi || -70;
|
||||
pointLight.intensity = 1 + Math.abs(rssi + 50) * 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
// FPS counter
|
||||
state.frameCount++;
|
||||
if (performance.now() - state.lastFpsTime >= 1000) {
|
||||
onFps(state.frameCount);
|
||||
state.frameCount = 0;
|
||||
state.lastFpsTime = performance.now();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
onReady();
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onMouseDown);
|
||||
canvas.removeEventListener('mouseup', onMouseUp);
|
||||
canvas.removeEventListener('mousemove', onMouseMove);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
window.removeEventListener('resize', onResize);
|
||||
cleanup();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Failed to initialize 3D renderer');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#0a0e1a' }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0e1a',
|
||||
},
|
||||
});
|
||||
|
||||
export default GaussianSplatWebViewWeb;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { StatusDot } from '@/components/StatusDot';
|
||||
import { ModeBadge } from '@/components/ModeBadge';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { formatConfidence, formatRssi } from '@/utils/formatters';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import type { ConnectionStatus } from '@/types/sensing';
|
||||
|
||||
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
|
||||
|
||||
type LiveHUDProps = {
|
||||
rssi?: number;
|
||||
connectionStatus: ConnectionStatus;
|
||||
fps: number;
|
||||
confidence: number;
|
||||
personCount: number;
|
||||
mode: LiveMode;
|
||||
};
|
||||
|
||||
const statusTextMap: Record<ConnectionStatus, string> = {
|
||||
connected: 'Connected',
|
||||
simulated: 'Simulated',
|
||||
connecting: 'Connecting',
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
const statusDotStatusMap: Record<ConnectionStatus, 'connected' | 'simulated' | 'disconnected' | 'connecting'> = {
|
||||
connected: 'connected',
|
||||
simulated: 'simulated',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'disconnected',
|
||||
};
|
||||
|
||||
export const LiveHUD = memo(
|
||||
({ rssi, connectionStatus, fps, confidence, personCount, mode }: LiveHUDProps) => {
|
||||
const [panelVisible, setPanelVisible] = useState(true);
|
||||
const panelAlpha = useSharedValue(1);
|
||||
|
||||
const togglePanel = useCallback(() => {
|
||||
const next = !panelVisible;
|
||||
setPanelVisible(next);
|
||||
panelAlpha.value = withTiming(next ? 1 : 0, { duration: 220 });
|
||||
}, [panelAlpha, panelVisible]);
|
||||
|
||||
const animatedPanelStyle = useAnimatedStyle(() => ({
|
||||
opacity: panelAlpha.value,
|
||||
}));
|
||||
|
||||
const statusText = statusTextMap[connectionStatus];
|
||||
|
||||
return (
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={togglePanel}>
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, animatedPanelStyle]}>
|
||||
{/* App title */}
|
||||
<View style={styles.topLeft}>
|
||||
<ThemedText preset="labelLg" style={styles.appTitle}>
|
||||
WiFi-DensePose
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Status + FPS */}
|
||||
<View style={styles.topRight}>
|
||||
<View style={styles.row}>
|
||||
<StatusDot status={statusDotStatusMap[connectionStatus]} size={10} />
|
||||
<ThemedText preset="labelMd" style={styles.statusText}>
|
||||
{statusText}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{fps > 0 && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText preset="labelMd">{fps} FPS</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom panel */}
|
||||
<View style={styles.bottomPanel}>
|
||||
<View style={styles.bottomCell}>
|
||||
<ThemedText preset="bodySm">RSSI</ThemedText>
|
||||
<ThemedText preset="displayMd" style={styles.bigValue}>
|
||||
{formatRssi(rssi)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCell}>
|
||||
<ModeBadge mode={mode} />
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCellRight}>
|
||||
<ThemedText preset="bodySm">Confidence</ThemedText>
|
||||
<ThemedText preset="bodyMd" style={styles.metaText}>
|
||||
{formatConfidence(confidence)}
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm">People: {personCount}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topLeft: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
left: spacing.md,
|
||||
},
|
||||
appTitle: {
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
topRight: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statusText: {
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
bottomPanel: {
|
||||
position: 'absolute',
|
||||
left: spacing.sm,
|
||||
right: spacing.sm,
|
||||
bottom: spacing.sm,
|
||||
minHeight: 72,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(10,14,26,0.72)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(50,184,198,0.35)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
bottomCell: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
bottomCellRight: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
bigValue: {
|
||||
color: colors.accent,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaText: {
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
LiveHUD.displayName = 'LiveHUD';
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Platform, StyleSheet, View } from 'react-native';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { usePoseStream } from '@/hooks/usePoseStream';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
|
||||
import { LiveHUD } from './LiveHUD';
|
||||
|
||||
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
|
||||
|
||||
const getMode = (
|
||||
status: ConnectionStatus,
|
||||
isSimulated: boolean,
|
||||
frame: SensingFrame | null,
|
||||
): LiveMode => {
|
||||
if (isSimulated || frame?.source === 'simulated') return 'SIM';
|
||||
if (status === 'connected') return 'LIVE';
|
||||
return 'RSSI';
|
||||
};
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
type ViewerProps = {
|
||||
frame: SensingFrame | null;
|
||||
onReady: () => void;
|
||||
onFps: (fps: number) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
const WebLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
const [Viewer, setViewer] = useState<React.ComponentType<any> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import('./GaussianSplatWebView.web').then((mod) => {
|
||||
setViewer(() => mod.GaussianSplatWebViewWeb);
|
||||
}).catch(() => onError('Failed to load web viewer'));
|
||||
}, [onError]);
|
||||
|
||||
if (!Viewer) return null;
|
||||
return <Viewer frame={frame} onReady={onReady} onFps={onFps} onError={onError} />;
|
||||
};
|
||||
|
||||
const NativeLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
const webViewRef = useRef(null);
|
||||
const [WVComponent, setWVComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { GaussianSplatWebView } = require('./GaussianSplatWebView');
|
||||
setWVComponent(() => GaussianSplatWebView);
|
||||
} catch {
|
||||
onError('WebView not available on this platform');
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
if (!WVComponent) return null;
|
||||
|
||||
return (
|
||||
<WVComponent
|
||||
webViewRef={webViewRef}
|
||||
onMessage={(event: any) => {
|
||||
try {
|
||||
const data = typeof event.nativeEvent.data === 'string'
|
||||
? JSON.parse(event.nativeEvent.data)
|
||||
: event.nativeEvent.data;
|
||||
if (data.type === 'READY') onReady();
|
||||
else if (data.type === 'FPS_TICK') onFps(data.payload?.fps ?? 0);
|
||||
else if (data.type === 'ERROR') onError(data.payload?.message ?? 'Unknown error');
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
onError={() => onError('WebView renderer failed')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LiveScreen = () => {
|
||||
const { lastFrame, connectionStatus, isSimulated } = usePoseStream();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fps, setFps] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewerKey, setViewerKey] = useState(0);
|
||||
|
||||
const handleReady = useCallback(() => { setReady(true); setError(null); }, []);
|
||||
const handleFps = useCallback((f: number) => setFps(Math.max(0, Math.floor(f))), []);
|
||||
const handleError = useCallback((msg: string) => { setError(msg); setReady(false); }, []);
|
||||
const handleRetry = useCallback(() => { setError(null); setReady(false); setFps(0); setViewerKey((v) => v + 1); }, []);
|
||||
|
||||
const rssi = lastFrame?.features?.mean_rssi;
|
||||
const personCount = lastFrame?.classification?.presence ? 1 : 0;
|
||||
const mode = getMode(connectionStatus, isSimulated, lastFrame);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.fallbackWrap}>
|
||||
<ThemedText preset="bodyLg">Live visualization failed</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.errorText}>{error}</ThemedText>
|
||||
<Button title="Retry" onPress={handleRetry} />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<WebLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
|
||||
) : (
|
||||
<NativeLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
|
||||
)}
|
||||
|
||||
<LiveHUD
|
||||
connectionStatus={connectionStatus}
|
||||
fps={fps}
|
||||
rssi={rssi}
|
||||
confidence={lastFrame?.classification?.confidence ?? 0}
|
||||
personCount={personCount}
|
||||
mode={mode}
|
||||
/>
|
||||
|
||||
{!ready && (
|
||||
<View style={styles.loadingWrap}>
|
||||
<LoadingSpinner />
|
||||
<ThemedText preset="bodyMd" style={styles.loadingText}>Loading live renderer</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loadingWrap: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center', gap: spacing.md },
|
||||
loadingText: { color: colors.textSecondary },
|
||||
fallbackWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.lg },
|
||||
errorText: { textAlign: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import type { SensingFrame } from '@/types/sensing';
|
||||
|
||||
export type GaussianBridgeMessageType = 'READY' | 'FPS_TICK' | 'ERROR';
|
||||
|
||||
type BridgeMessage = {
|
||||
type: GaussianBridgeMessageType;
|
||||
payload?: {
|
||||
fps?: number;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const toJsonScript = (message: unknown): string => {
|
||||
const serialized = JSON.stringify(message);
|
||||
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(serialized)} })); true;`;
|
||||
};
|
||||
|
||||
export const useGaussianBridge = (webViewRef: RefObject<WebView | null>) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [fps, setFps] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const send = useCallback((message: unknown) => {
|
||||
const webView = webViewRef.current;
|
||||
if (!webView) {
|
||||
return;
|
||||
}
|
||||
|
||||
webView.injectJavaScript(toJsonScript(message));
|
||||
}, [webViewRef]);
|
||||
|
||||
const sendFrame = useCallback(
|
||||
(frame: SensingFrame) => {
|
||||
send({
|
||||
type: 'FRAME_UPDATE',
|
||||
payload: frame,
|
||||
});
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const onMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
let parsed: BridgeMessage | null = null;
|
||||
const raw = event.nativeEvent.data;
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as BridgeMessage;
|
||||
} catch {
|
||||
setError('Invalid bridge message format');
|
||||
return;
|
||||
}
|
||||
} else if (typeof raw === 'object' && raw !== null) {
|
||||
parsed = raw as BridgeMessage;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'READY') {
|
||||
setIsReady(true);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'FPS_TICK') {
|
||||
const fpsValue = parsed.payload?.fps;
|
||||
if (typeof fpsValue === 'number' && Number.isFinite(fpsValue)) {
|
||||
setFps(Math.max(0, Math.floor(fpsValue)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'ERROR') {
|
||||
setError(parsed.payload?.message ?? 'Unknown bridge error');
|
||||
setIsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendFrame,
|
||||
onMessage,
|
||||
isReady,
|
||||
fps,
|
||||
error,
|
||||
reset: () => {
|
||||
setIsReady(false);
|
||||
setFps(0);
|
||||
setError(null);
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user