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 (