Files
Lars Nolden b463af35d0 spark 2.1.0 splat viewer tab with OS-fused gyro look-around
WebView-hosted Spark renderer (butterfly.spz demo scene), fixed camera
position, rotation driven by expo-sensors DeviceMotion (ROTATION_VECTOR /
CoreMotion xMagneticNorthZVertical) via injectJavaScript; web fallback
uses deviceorientation in an iframe. Tap to re-center yaw.
2026-08-11 18:05:17 +02:00

118 lines
3.2 KiB
TypeScript

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<WebView>(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 (
<View style={styles.container}>
<WebView
ref={webViewRef}
source={{ html: SPLAT_VIEWER_HTML }}
originWhitelist={['*']}
onMessage={onMessage}
scrollEnabled={false}
bounces={false}
overScrollMode="never"
setBuiltInZoomControls={false}
webviewDebuggingEnabled={__DEV__}
style={styles.webview}
/>
<Text style={styles.status}>{status}</Text>
</View>
);
}
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',
},
});