import { DeviceMotion } from 'expo-sensors'; import { useEffect, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import { rotationScript, SPLAT_VIEWER_HTML, type ViewerMessage } from './viewer-html'; const SENSOR_UPDATE_INTERVAL_MS = 1000 / 60; /** * Native (iOS/Android) splat viewer host. * * Renders the Spark viewer page in a WebView and streams the OS's fused * device attitude (expo-sensors `DeviceMotion.rotation`: ROTATION_VECTOR on * Android, CoreMotion attitude on iOS — gyro+accel+mag on both) into the * page via `injectJavaScript`. Camera position stays fixed; only rotation * is live. */ export function SplatViewer() { const webViewRef = useRef(null); const [status, setStatus] = useState('Loading Spark renderer…'); useEffect(() => { let subscription: { remove(): void } | undefined; let cancelled = false; (async () => { const available = await DeviceMotion.isAvailableAsync(); if (cancelled) return; if (!available) { setStatus('Motion sensors unavailable — showing a static view.'); return; } DeviceMotion.setUpdateInterval(SENSOR_UPDATE_INTERVAL_MS); subscription = DeviceMotion.addListener(({ rotation, orientation }) => { if (!rotation) return; const script = rotationScript({ alpha: rotation.alpha, beta: rotation.beta, gamma: rotation.gamma, orientation: orientation ?? 0, }); if (script) webViewRef.current?.injectJavaScript(script); }); })(); return () => { cancelled = true; subscription?.remove(); }; }, []); const onMessage = (event: WebViewMessageEvent) => { let message: ViewerMessage; try { message = JSON.parse(event.nativeEvent.data); } catch { return; } switch (message.type) { case 'progress': setStatus(`Downloading scene… ${Math.round((100 * message.loaded) / message.total)}%`); break; case 'ready': setStatus(`${message.numSplats.toLocaleString()} splats — move the phone to look around, tap to re-center.`); break; case 'error': setStatus(`Viewer error: ${message.message}`); break; case 'stats': break; } }; return ( {status} ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000', }, webview: { flex: 1, backgroundColor: '#000', }, status: { position: 'absolute', bottom: 12, left: 12, right: 12, color: '#fff', fontSize: 12, textAlign: 'center', backgroundColor: 'rgba(0,0,0,0.55)', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 10, overflow: 'hidden', }, });