diff --git a/app.json b/app.json
index 71ce722..318707b 100644
--- a/app.json
+++ b/app.json
@@ -27,6 +27,12 @@
},
"plugins": [
"expo-router",
+ [
+ "expo-sensors",
+ {
+ "motionPermission": "Allow $(PRODUCT_NAME) to use device motion to rotate the 3D view."
+ }
+ ],
[
"expo-splash-screen",
{
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 54e11d0..a517219 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -23,6 +23,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => ,
}}
/>
+ ,
+ }}
+ />
;
+}
diff --git a/components/splat-viewer/index.tsx b/components/splat-viewer/index.tsx
new file mode 100644
index 0000000..45530a1
--- /dev/null
+++ b/components/splat-viewer/index.tsx
@@ -0,0 +1,117 @@
+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',
+ },
+});
diff --git a/components/splat-viewer/index.web.tsx b/components/splat-viewer/index.web.tsx
new file mode 100644
index 0000000..c8816c3
--- /dev/null
+++ b/components/splat-viewer/index.web.tsx
@@ -0,0 +1,153 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { SPLAT_VIEWER_HTML, type ViewerMessage } from './viewer-html';
+
+// expo-sensors' DeviceMotion web shim does not emit `rotation`, so on web we
+// read the browser's own OS-fused attitude directly. Chrome/Android exposes
+// the magnetometer-referenced fusion on `deviceorientationabsolute`; other
+// browsers (incl. iOS Safari, which fuses via CoreMotion) use
+// `deviceorientation`.
+const ORIENTATION_EVENT =
+ typeof window !== 'undefined' && 'ondeviceorientationabsolute' in window
+ ? 'deviceorientationabsolute'
+ : 'deviceorientation';
+
+// iOS Safari 13+ gates motion events behind a user-gesture permission prompt.
+const orientationEventStatics =
+ typeof DeviceOrientationEvent !== 'undefined'
+ ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> })
+ : {};
+
+const DEG2RAD = Math.PI / 180;
+
+/**
+ * Web splat viewer host: same Spark viewer page as native, hosted in an
+ * iframe and fed rotation samples via postMessage. Camera position is fixed.
+ */
+export function SplatViewer() {
+ const frameRef = useRef(null);
+ const [status, setStatus] = useState('Loading Spark renderer…');
+ const [needsPermission, setNeedsPermission] = useState(false);
+
+ const onOrientation = useCallback((event: DeviceOrientationEvent) => {
+ if (event.alpha == null || event.beta == null || event.gamma == null) return;
+ frameRef.current?.contentWindow?.postMessage(
+ {
+ type: 'rotation',
+ alpha: event.alpha * DEG2RAD,
+ beta: event.beta * DEG2RAD,
+ gamma: event.gamma * DEG2RAD,
+ orientation: (typeof screen !== 'undefined' && screen.orientation?.angle) || 0,
+ },
+ '*',
+ );
+ }, []);
+
+ useEffect(() => {
+ const onViewerMessage = (event: MessageEvent) => {
+ if (event.source !== frameRef.current?.contentWindow) return;
+ const message = event.data as ViewerMessage;
+ switch (message?.type) {
+ case 'progress':
+ setStatus(`Downloading scene… ${Math.round((100 * message.loaded) / message.total)}%`);
+ break;
+ case 'ready':
+ setStatus(`${message.numSplats.toLocaleString()} splats — rotate the device to look around, tap to re-center.`);
+ break;
+ case 'error':
+ setStatus(`Viewer error: ${message.message}`);
+ break;
+ }
+ };
+ window.addEventListener('message', onViewerMessage);
+
+ const listener = onOrientation as EventListener;
+ if (typeof orientationEventStatics.requestPermission === 'function') {
+ setNeedsPermission(true);
+ } else {
+ window.addEventListener(ORIENTATION_EVENT, listener);
+ }
+ return () => {
+ window.removeEventListener('message', onViewerMessage);
+ window.removeEventListener(ORIENTATION_EVENT, listener);
+ };
+ }, [onOrientation]);
+
+ const requestMotionAccess = useCallback(async () => {
+ try {
+ const response = await orientationEventStatics.requestPermission?.();
+ if (response === 'granted') {
+ window.addEventListener(ORIENTATION_EVENT, onOrientation as EventListener);
+ setNeedsPermission(false);
+ } else {
+ setStatus('Motion access denied — showing a static view.');
+ setNeedsPermission(false);
+ }
+ } catch (error) {
+ setStatus(`Motion permission failed: ${error instanceof Error ? error.message : error}`);
+ }
+ }, [onOrientation]);
+
+ return (
+
+
+ {needsPermission && (
+
+ Enable motion control
+
+ )}
+ {status}
+
+ );
+}
+
+const iframeStyle: React.CSSProperties = {
+ position: 'absolute',
+ inset: 0,
+ width: '100%',
+ height: '100%',
+ border: 0,
+ background: '#000',
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#000',
+ },
+ permissionButton: {
+ position: 'absolute',
+ top: '45%',
+ alignSelf: 'center',
+ backgroundColor: 'rgba(10, 126, 164, 0.9)',
+ borderRadius: 10,
+ paddingVertical: 12,
+ paddingHorizontal: 20,
+ },
+ permissionLabel: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '600',
+ },
+ 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',
+ },
+});
diff --git a/components/splat-viewer/viewer-html.ts b/components/splat-viewer/viewer-html.ts
new file mode 100644
index 0000000..7a5cae1
--- /dev/null
+++ b/components/splat-viewer/viewer-html.ts
@@ -0,0 +1,226 @@
+/**
+ * Self-contained Spark (sparkjs.dev) Gaussian-splat viewer page.
+ *
+ * Hosted inside `react-native-webview` on iOS/Android and inside an