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 ( + +