feat: Live screen with 3D Gaussian splat WebView
This commit is contained in:
@@ -0,0 +1,585 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||||
|
/>
|
||||||
|
<title>WiFi DensePose Splat Viewer</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#gaussian-splat-root {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0a0e1a;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gaussian-splat-root {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="gaussian-splat-root"></div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r165/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.165.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const postMessageToRN = (message) => {
|
||||||
|
if (!window.ReactNativeWebView || typeof window.ReactNativeWebView.postMessage !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to post RN message', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const postError = (message) => {
|
||||||
|
postMessageToRN({
|
||||||
|
type: 'ERROR',
|
||||||
|
payload: {
|
||||||
|
message: typeof message === 'string' ? message : 'Unknown bridge error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use global THREE from CDN
|
||||||
|
const getThree = () => window.THREE;
|
||||||
|
|
||||||
|
// ---- Custom Splat Shaders --------------------------------------------
|
||||||
|
|
||||||
|
const SPLAT_VERTEX = `
|
||||||
|
attribute float splatSize;
|
||||||
|
attribute vec3 splatColor;
|
||||||
|
attribute float splatOpacity;
|
||||||
|
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vOpacity;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vColor = splatColor;
|
||||||
|
vOpacity = splatOpacity;
|
||||||
|
|
||||||
|
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
gl_PointSize = splatSize * (300.0 / -mvPosition.z);
|
||||||
|
gl_Position = projectionMatrix * mvPosition;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SPLAT_FRAGMENT = `
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vOpacity;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Circular soft-edge disc
|
||||||
|
float dist = length(gl_PointCoord - vec2(0.5));
|
||||||
|
if (dist > 0.5) discard;
|
||||||
|
float alpha = smoothstep(0.5, 0.2, dist) * vOpacity;
|
||||||
|
gl_FragColor = vec4(vColor, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---- Color helpers ---------------------------------------------------
|
||||||
|
|
||||||
|
/** Map a scalar 0-1 to blue -> green -> red gradient */
|
||||||
|
function valueToColor(v) {
|
||||||
|
const clamped = Math.max(0, Math.min(1, v));
|
||||||
|
// blue(0) -> cyan(0.25) -> green(0.5) -> yellow(0.75) -> red(1)
|
||||||
|
let r;
|
||||||
|
let g;
|
||||||
|
let b;
|
||||||
|
if (clamped < 0.5) {
|
||||||
|
const t = clamped * 2;
|
||||||
|
r = 0;
|
||||||
|
g = t;
|
||||||
|
b = 1 - t;
|
||||||
|
} else {
|
||||||
|
const t = (clamped - 0.5) * 2;
|
||||||
|
r = t;
|
||||||
|
g = 1 - t;
|
||||||
|
b = 0;
|
||||||
|
}
|
||||||
|
return [r, g, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GaussianSplatRenderer -------------------------------------------
|
||||||
|
|
||||||
|
class GaussianSplatRenderer {
|
||||||
|
/** @param {HTMLElement} container - DOM element to attach the renderer to */
|
||||||
|
constructor(container, opts = {}) {
|
||||||
|
const THREE = getThree();
|
||||||
|
if (!THREE) {
|
||||||
|
throw new Error('Three.js not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container = container;
|
||||||
|
this.width = opts.width || container.clientWidth || 800;
|
||||||
|
this.height = opts.height || 500;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = new THREE.Color(0x0a0e1a);
|
||||||
|
|
||||||
|
// Camera — perspective looking down at the room
|
||||||
|
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 200);
|
||||||
|
this.camera.position.set(0, 10, 12);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
this.renderer.setSize(this.width, this.height);
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
container.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
const ambient = new THREE.AmbientLight(0x9ec7ff, 0.35);
|
||||||
|
this.scene.add(ambient);
|
||||||
|
|
||||||
|
const directional = new THREE.DirectionalLight(0x9ec7ff, 0.65);
|
||||||
|
directional.position.set(4, 10, 6);
|
||||||
|
directional.castShadow = false;
|
||||||
|
this.scene.add(directional);
|
||||||
|
|
||||||
|
// Grid & room
|
||||||
|
this._createRoom(THREE);
|
||||||
|
|
||||||
|
// Signal field splats (20x20 = 400 points on the floor plane)
|
||||||
|
this.gridSize = 20;
|
||||||
|
this._createFieldSplats(THREE);
|
||||||
|
|
||||||
|
// Node markers (ESP32 / router positions)
|
||||||
|
this._createNodeMarkers(THREE);
|
||||||
|
|
||||||
|
// Body disruption blob
|
||||||
|
this._createBodyBlob(THREE);
|
||||||
|
|
||||||
|
// Orbit controls for drag + pinch zoom
|
||||||
|
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
|
||||||
|
this.controls.target.set(0, 0, 0);
|
||||||
|
this.controls.minDistance = 6;
|
||||||
|
this.controls.maxDistance = 40;
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.08;
|
||||||
|
this.controls.update();
|
||||||
|
|
||||||
|
// Animation state
|
||||||
|
this._animFrame = null;
|
||||||
|
this._lastData = null;
|
||||||
|
this._fpsFrames = [];
|
||||||
|
this._lastFpsReport = 0;
|
||||||
|
|
||||||
|
// Start render loop
|
||||||
|
this._animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scene setup ---------------------------------------------------
|
||||||
|
|
||||||
|
_createRoom(THREE) {
|
||||||
|
// Floor grid (on y = 0), 20 units
|
||||||
|
const grid = new THREE.GridHelper(20, 20, 0x1a3a4a, 0x0d1f28);
|
||||||
|
grid.position.y = 0;
|
||||||
|
this.scene.add(grid);
|
||||||
|
|
||||||
|
// Room boundary wireframe
|
||||||
|
const boxGeo = new THREE.BoxGeometry(20, 6, 20);
|
||||||
|
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||||
|
const line = new THREE.LineSegments(
|
||||||
|
edges,
|
||||||
|
new THREE.LineBasicMaterial({ color: 0x1a4a5a, opacity: 0.3, transparent: true }),
|
||||||
|
);
|
||||||
|
line.position.y = 3;
|
||||||
|
this.scene.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createFieldSplats(THREE) {
|
||||||
|
const count = this.gridSize * this.gridSize;
|
||||||
|
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const sizes = new Float32Array(count);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
const opacities = new Float32Array(count);
|
||||||
|
|
||||||
|
// Lay splats on the floor plane (y = 0.05 to sit just above grid)
|
||||||
|
for (let iz = 0; iz < this.gridSize; iz++) {
|
||||||
|
for (let ix = 0; ix < this.gridSize; ix++) {
|
||||||
|
const idx = iz * this.gridSize + ix;
|
||||||
|
positions[idx * 3 + 0] = (ix - this.gridSize / 2) + 0.5; // x
|
||||||
|
positions[idx * 3 + 1] = 0.05; // y
|
||||||
|
positions[idx * 3 + 2] = (iz - this.gridSize / 2) + 0.5; // z
|
||||||
|
|
||||||
|
sizes[idx] = 1.5;
|
||||||
|
colors[idx * 3] = 0.1;
|
||||||
|
colors[idx * 3 + 1] = 0.2;
|
||||||
|
colors[idx * 3 + 2] = 0.6;
|
||||||
|
opacities[idx] = 0.15;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||||
|
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||||
|
geo.setAttribute('splatOpacity', new THREE.BufferAttribute(opacities, 1));
|
||||||
|
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: SPLAT_VERTEX,
|
||||||
|
fragmentShader: SPLAT_FRAGMENT,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fieldPoints = new THREE.Points(geo, mat);
|
||||||
|
this.scene.add(this.fieldPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createNodeMarkers(THREE) {
|
||||||
|
// Router at center — green sphere
|
||||||
|
const routerGeo = new THREE.SphereGeometry(0.3, 16, 16);
|
||||||
|
const routerMat = new THREE.MeshBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.8 });
|
||||||
|
this.routerMarker = new THREE.Mesh(routerGeo, routerMat);
|
||||||
|
this.routerMarker.position.set(0, 0.5, 0);
|
||||||
|
this.scene.add(this.routerMarker);
|
||||||
|
|
||||||
|
// ESP32 node — cyan sphere (default position, updated from data)
|
||||||
|
const nodeGeo = new THREE.SphereGeometry(0.25, 16, 16);
|
||||||
|
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x00ccff, transparent: true, opacity: 0.8 });
|
||||||
|
this.nodeMarker = new THREE.Mesh(nodeGeo, nodeMat);
|
||||||
|
this.nodeMarker.position.set(2, 0.5, 1.5);
|
||||||
|
this.scene.add(this.nodeMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createBodyBlob(THREE) {
|
||||||
|
// A cluster of splats representing body disruption
|
||||||
|
const count = 64;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const sizes = new Float32Array(count);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
const opacities = new Float32Array(count);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Random sphere distribution
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
const r = Math.random() * 1.5;
|
||||||
|
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
|
positions[i * 3 + 1] = r * Math.cos(phi) + 2;
|
||||||
|
positions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
|
|
||||||
|
sizes[i] = 2 + Math.random() * 3;
|
||||||
|
colors[i * 3] = 0.2;
|
||||||
|
colors[i * 3 + 1] = 0.8;
|
||||||
|
colors[i * 3 + 2] = 0.3;
|
||||||
|
opacities[i] = 0.0; // hidden until presence detected
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||||
|
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||||
|
geo.setAttribute('splatOpacity', new THREE.BufferAttribute(opacities, 1));
|
||||||
|
|
||||||
|
const mat = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: SPLAT_VERTEX,
|
||||||
|
fragmentShader: SPLAT_FRAGMENT,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.bodyBlob = new THREE.Points(geo, mat);
|
||||||
|
this.scene.add(this.bodyBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Data update --------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the visualization with new sensing data.
|
||||||
|
* @param {object} data - sensing_update JSON from ws_server
|
||||||
|
*/
|
||||||
|
update(data) {
|
||||||
|
this._lastData = data;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const features = data.features || {};
|
||||||
|
const classification = data.classification || {};
|
||||||
|
const signalField = data.signal_field || {};
|
||||||
|
const nodes = data.nodes || [];
|
||||||
|
|
||||||
|
// -- Update signal field splats ------------------------------------
|
||||||
|
if (signalField.values && this.fieldPoints) {
|
||||||
|
const geo = this.fieldPoints.geometry;
|
||||||
|
const clr = geo.attributes.splatColor.array;
|
||||||
|
const sizes = geo.attributes.splatSize.array;
|
||||||
|
const opac = geo.attributes.splatOpacity.array;
|
||||||
|
const vals = signalField.values;
|
||||||
|
const count = Math.min(vals.length, this.gridSize * this.gridSize);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const v = vals[i];
|
||||||
|
const [r, g, b] = valueToColor(v);
|
||||||
|
clr[i * 3] = r;
|
||||||
|
clr[i * 3 + 1] = g;
|
||||||
|
clr[i * 3 + 2] = b;
|
||||||
|
sizes[i] = 1.0 + v * 4.0;
|
||||||
|
opac[i] = 0.1 + v * 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
geo.attributes.splatColor.needsUpdate = true;
|
||||||
|
geo.attributes.splatSize.needsUpdate = true;
|
||||||
|
geo.attributes.splatOpacity.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update body blob ----------------------------------------------
|
||||||
|
if (this.bodyBlob) {
|
||||||
|
const bGeo = this.bodyBlob.geometry;
|
||||||
|
const bOpac = bGeo.attributes.splatOpacity.array;
|
||||||
|
const bClr = bGeo.attributes.splatColor.array;
|
||||||
|
const bSize = bGeo.attributes.splatSize.array;
|
||||||
|
|
||||||
|
const presence = classification.presence || false;
|
||||||
|
const motionLvl = classification.motion_level || 'absent';
|
||||||
|
const confidence = classification.confidence || 0;
|
||||||
|
const breathing = features.breathing_band_power || 0;
|
||||||
|
|
||||||
|
// Breathing pulsation
|
||||||
|
const breathPulse = 1.0 + Math.sin(Date.now() * 0.004) * Math.min(breathing * 3, 0.4);
|
||||||
|
|
||||||
|
for (let i = 0; i < bOpac.length; i++) {
|
||||||
|
if (presence) {
|
||||||
|
bOpac[i] = confidence * 0.4;
|
||||||
|
|
||||||
|
// Color by motion level
|
||||||
|
if (motionLvl === 'active') {
|
||||||
|
bClr[i * 3] = 1.0;
|
||||||
|
bClr[i * 3 + 1] = 0.2;
|
||||||
|
bClr[i * 3 + 2] = 0.1;
|
||||||
|
} else {
|
||||||
|
bClr[i * 3] = 0.1;
|
||||||
|
bClr[i * 3 + 1] = 0.8;
|
||||||
|
bClr[i * 3 + 2] = 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
bSize[i] = (2 + Math.random() * 2) * breathPulse;
|
||||||
|
} else {
|
||||||
|
bOpac[i] = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bGeo.attributes.splatOpacity.needsUpdate = true;
|
||||||
|
bGeo.attributes.splatColor.needsUpdate = true;
|
||||||
|
bGeo.attributes.splatSize.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update node positions -----------------------------------------
|
||||||
|
if (nodes.length > 0 && nodes[0].position && this.nodeMarker) {
|
||||||
|
const pos = nodes[0].position;
|
||||||
|
this.nodeMarker.position.set(pos[0], 0.5, pos[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Render loop -------------------------------------------------
|
||||||
|
|
||||||
|
_animate() {
|
||||||
|
this._animFrame = requestAnimationFrame(() => this._animate());
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
// Gentle router glow pulse
|
||||||
|
if (this.routerMarker) {
|
||||||
|
const pulse = 0.6 + 0.3 * Math.sin(now * 0.003);
|
||||||
|
this.routerMarker.material.opacity = pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls.update();
|
||||||
|
this.renderer.render(this.scene, this.camera);
|
||||||
|
|
||||||
|
this._fpsFrames.push(now);
|
||||||
|
while (this._fpsFrames.length > 0 && this._fpsFrames[0] < now - 1000) {
|
||||||
|
this._fpsFrames.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - this._lastFpsReport >= 1000) {
|
||||||
|
const fps = this._fpsFrames.length;
|
||||||
|
this._lastFpsReport = now;
|
||||||
|
postMessageToRN({
|
||||||
|
type: 'FPS_TICK',
|
||||||
|
payload: { fps },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Resize / cleanup --------------------------------------------
|
||||||
|
|
||||||
|
resize(width, height) {
|
||||||
|
if (!width || !height) return;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.camera.aspect = width / height;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._animFrame) {
|
||||||
|
cancelAnimationFrame(this._animFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.controls?.dispose();
|
||||||
|
this.renderer.dispose();
|
||||||
|
if (this.renderer.domElement.parentNode) {
|
||||||
|
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose renderer constructor for debugging/interop
|
||||||
|
window.GaussianSplatRenderer = GaussianSplatRenderer;
|
||||||
|
|
||||||
|
let renderer = null;
|
||||||
|
let pendingFrame = null;
|
||||||
|
let pendingResize = null;
|
||||||
|
|
||||||
|
const postSafeReady = () => {
|
||||||
|
postMessageToRN({ type: 'READY' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeMessage = (event) => {
|
||||||
|
let raw = event.data;
|
||||||
|
if (typeof raw === 'object' && raw != null && 'data' in raw) {
|
||||||
|
raw = raw.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try {
|
||||||
|
message = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
postError('Failed to parse RN message payload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'FRAME_UPDATE') {
|
||||||
|
const payload = message.payload || null;
|
||||||
|
if (!payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderer) {
|
||||||
|
pendingFrame = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderer.update(payload);
|
||||||
|
} catch (error) {
|
||||||
|
postError((error && error.message) || 'Failed to update frame');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'RESIZE') {
|
||||||
|
const dims = message.payload || {};
|
||||||
|
const w = Number(dims.width);
|
||||||
|
const h = Number(dims.height);
|
||||||
|
if (!Number.isFinite(w) || !Number.isFinite(h) || !w || !h) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!renderer) {
|
||||||
|
pendingResize = { width: w, height: h };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderer.resize(w, h);
|
||||||
|
} catch (error) {
|
||||||
|
postError((error && error.message) || 'Failed to resize renderer');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISPOSE') {
|
||||||
|
if (!renderer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderer.dispose();
|
||||||
|
} catch (error) {
|
||||||
|
postError((error && error.message) || 'Failed to dispose renderer');
|
||||||
|
}
|
||||||
|
renderer = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRenderer = () => {
|
||||||
|
const container = document.getElementById('gaussian-splat-root');
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderer = new GaussianSplatRenderer(container, {
|
||||||
|
width: container.clientWidth || window.innerWidth,
|
||||||
|
height: container.clientHeight || window.innerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingFrame) {
|
||||||
|
renderer.update(pendingFrame);
|
||||||
|
pendingFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingResize) {
|
||||||
|
renderer.resize(pendingResize.width, pendingResize.height);
|
||||||
|
pendingResize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
postSafeReady();
|
||||||
|
} catch (error) {
|
||||||
|
renderer = null;
|
||||||
|
postError((error && error.message) || 'Failed to initialize renderer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', buildRenderer);
|
||||||
|
} else {
|
||||||
|
buildRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', routeMessage);
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (!renderer) {
|
||||||
|
pendingResize = {
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderer.resize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -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,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,215 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, LayoutChangeEvent, StyleSheet, View } from 'react-native';
|
||||||
|
import type { WebView } from 'react-native-webview';
|
||||||
|
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||||
|
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 { useGaussianBridge } from './useGaussianBridge';
|
||||||
|
import { GaussianSplatWebView } from './GaussianSplatWebView';
|
||||||
|
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 dispatchWebViewMessage = (webViewRef: { current: WebView | null }, message: unknown) => {
|
||||||
|
const webView = webViewRef.current;
|
||||||
|
if (!webView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
webView.injectJavaScript(
|
||||||
|
`window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(payload)} })); true;`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LiveScreen = () => {
|
||||||
|
const webViewRef = useRef<WebView | null>(null);
|
||||||
|
const { lastFrame, connectionStatus, isSimulated } = usePoseStream();
|
||||||
|
const bridge = useGaussianBridge(webViewRef);
|
||||||
|
|
||||||
|
const [webError, setWebError] = useState<string | null>(null);
|
||||||
|
const [viewerKey, setViewerKey] = useState(0);
|
||||||
|
const sendTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingFrameRef = useRef<SensingFrame | null>(null);
|
||||||
|
const lastSentAtRef = useRef(0);
|
||||||
|
|
||||||
|
const clearSendTimeout = useCallback(() => {
|
||||||
|
if (!sendTimeoutRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(sendTimeoutRef.current);
|
||||||
|
sendTimeoutRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFrameRef.current = lastFrame;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (!bridge.isReady || !pendingFrameRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bridge.sendFrame(pendingFrameRef.current);
|
||||||
|
lastSentAtRef.current = Date.now();
|
||||||
|
pendingFrameRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitMs = Math.max(0, 500 - (now - lastSentAtRef.current));
|
||||||
|
|
||||||
|
if (waitMs <= 0) {
|
||||||
|
flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSendTimeout();
|
||||||
|
sendTimeoutRef.current = setTimeout(() => {
|
||||||
|
sendTimeoutRef.current = null;
|
||||||
|
flush();
|
||||||
|
}, waitMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearSendTimeout();
|
||||||
|
};
|
||||||
|
}, [bridge.isReady, lastFrame, bridge.sendFrame, clearSendTimeout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatchWebViewMessage(webViewRef, { type: 'DISPOSE' });
|
||||||
|
clearSendTimeout();
|
||||||
|
pendingFrameRef.current = null;
|
||||||
|
};
|
||||||
|
}, [clearSendTimeout]);
|
||||||
|
|
||||||
|
const onMessage = useCallback(
|
||||||
|
(event: WebViewMessageEvent) => {
|
||||||
|
bridge.onMessage(event);
|
||||||
|
},
|
||||||
|
[bridge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||||
|
const { width, height } = event.nativeEvent.layout;
|
||||||
|
if (width <= 0 || height <= 0 || Number.isNaN(width) || Number.isNaN(height)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchWebViewMessage(webViewRef, {
|
||||||
|
type: 'RESIZE',
|
||||||
|
payload: {
|
||||||
|
width: Math.max(1, Math.floor(width)),
|
||||||
|
height: Math.max(1, Math.floor(height)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWebError = useCallback(() => {
|
||||||
|
setWebError('Live renderer failed to initialize');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
setWebError(null);
|
||||||
|
bridge.reset();
|
||||||
|
setViewerKey((value) => value + 1);
|
||||||
|
}, [bridge]);
|
||||||
|
|
||||||
|
const rssi = lastFrame?.features?.mean_rssi;
|
||||||
|
const personCount = lastFrame?.classification?.presence ? 1 : 0;
|
||||||
|
const mode = getMode(connectionStatus, isSimulated, lastFrame);
|
||||||
|
|
||||||
|
if (webError || bridge.error) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.fallbackWrap}>
|
||||||
|
<ThemedText preset="bodyLg">Live visualization failed</ThemedText>
|
||||||
|
<ThemedText preset="bodySm" color="textSecondary" style={styles.errorText}>
|
||||||
|
{webError ?? bridge.error}
|
||||||
|
</ThemedText>
|
||||||
|
<Button title="Retry" onPress={handleRetry} />
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<GaussianSplatWebView
|
||||||
|
key={viewerKey}
|
||||||
|
webViewRef={webViewRef}
|
||||||
|
onMessage={onMessage}
|
||||||
|
onError={handleWebError}
|
||||||
|
onLayout={onLayout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LiveHUD
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
fps={bridge.fps}
|
||||||
|
rssi={rssi}
|
||||||
|
confidence={lastFrame?.classification?.confidence ?? 0}
|
||||||
|
personCount={personCount}
|
||||||
|
mode={mode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!bridge.isReady && (
|
||||||
|
<View style={styles.loadingWrap}>
|
||||||
|
<LoadingSpinner />
|
||||||
|
<ThemedText preset="bodyMd" style={styles.loadingText}>
|
||||||
|
Loading live renderer
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
4
mobile/src/types/html.d.ts
vendored
Normal file
4
mobile/src/types/html.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.html' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user