ar-test/app/qr-ar/ar-viewer.tsx

219 lines
8.2 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { Box } from 'lucide-react';
import { pb } from '~/lib/pocketbase';
// --- A-FRAME/AR.JS CUSTOM ELEMENTS TYPING FIX ---
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'a-scene': any;
'a-assets': any;
'a-asset-item': any;
'a-marker': any;
'a-entity': any;
'a-text': any;
'model-viewer': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
> & {
src?: string;
'ios-src'?: string;
ar?: boolean;
'ar-modes'?: string;
'ar-scale'?: string;
'ar-placement'?: string;
'camera-controls'?: boolean;
'touch-action'?: string;
alt?: string;
'shadow-intensity'?: string | number;
'shadow-softness'?: string | number;
exposure?: string | number;
'interaction-prompt'?: string;
'min-camera-orbit'?: string;
'max-camera-orbit'?: string;
'camera-orbit'?: string;
'field-of-view'?: string;
scale?: string;
'auto-rotate'?: boolean;
'rotation-per-second'?: string;
onLoad?: (e: any) => void;
'onAr-status'?: (e: any) => void;
'onModel-visibility'?: (e: any) => void;
'onCamera-change'?: (e: any) => void;
};
}
}
}
const AR_HINTS = [
'Arranging models to view…',
'Find a flat surface & step back…',
'Keep your camera steady…',
'Almost ready, clear some space…',
'Preparing your AR experience…',
];
export default function ARViewer() {
const { id } = useParams();
const modelViewerRef = useRef<HTMLElement>(null);
const [asset, setAsset] = useState<any>(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
const [arLoading, setArLoading] = useState(false);
const [showHints, setShowHints] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
const hintIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hintDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!id) return;
const loadAsset = async () => {
const data = await pb.collection('ar_assets').getOne(id);
if (data) setAsset(data);
};
loadAsset();
}, [id]);
useEffect(() => {
if (document.getElementById('model-viewer-script')) {
setScriptLoaded(true);
return;
}
const script = document.createElement('script');
script.id = 'model-viewer-script';
script.type = 'module';
script.src = 'https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js';
script.onload = () => setScriptLoaded(true);
document.head.appendChild(script);
}, []);
const handleARClick = () => {
const mv = modelViewerRef.current as any;
if (!mv?.activateAR || arLoading) return;
setArLoading(true);
setShowHints(false);
setHintIndex(0);
mv.activateAR();
const cleanup = () => {
setArLoading(false);
setShowHints(false);
if (hintIntervalRef.current) clearInterval(hintIntervalRef.current);
if (hintDelayRef.current) clearTimeout(hintDelayRef.current);
};
// Only show hints if AR is still loading after 1.5s
hintDelayRef.current = setTimeout(() => {
setShowHints(true);
hintIntervalRef.current = setInterval(() => {
setHintIndex((prev) => (prev + 1) % AR_HINTS.length);
}, 2000);
}, 1500);
const onArStatus = (e: any) => {
const { status } = e.detail ?? {};
if (
status === 'session-started' ||
status === 'object-placed' ||
status === 'failed' ||
status === 'not-presenting'
) {
cleanup();
mv.removeEventListener('ar-status', onArStatus);
}
};
mv.addEventListener('ar-status', onArStatus);
// Fallback reset after 15s
setTimeout(cleanup, 15000);
};
const glbUrl = asset?.glb_file
? pb.files.getURL(asset, asset.glb_file)
: asset?.glb_url;
const usdzUrl = asset?.usdz_file
? pb.files.getURL(asset, asset.usdz_file)
: asset?.usdz_url;
if (!scriptLoaded || !asset) {
return (
<div className="fixed inset-0 bg-black flex flex-col items-center justify-center">
<div className="w-10 h-10 rounded-full border-2 border-orange-500/20 border-t-orange-500 animate-spin mb-4" />
<p className="text-white/40 text-xs tracking-widest uppercase">Loading AR Experience</p>
</div>
);
}
return (
<div className="fixed inset-0 bg-black flex flex-col overflow-hidden">
<style>{`
model-viewer::part(default-ar-button) { display: none !important; }
`}</style>
<model-viewer
ref={modelViewerRef}
src={glbUrl}
ios-src={usdzUrl}
ar
ar-modes="webxr scene-viewer quick-look"
ar-scale="auto"
ar-placement="floor"
camera-controls
touch-action="none"
interaction-prompt="none"
shadow-intensity="1"
shadow-softness="0.8"
exposure="1"
className="w-full flex-1 min-h-0 block"
onError={(e: any) => {
console.error('Model viewer error:', e);
console.error('Failed to load:', glbUrl);
}}
onLoad={() => console.log('Model loaded successfully')}
/>
<div className="absolute top-0 left-0 right-0 flex items-start justify-between px-5 pt-5 pb-5 pointer-events-none">
{asset?.name && (
<span className="text-white text-sm font-bold tracking-widest uppercase">
{asset.name}
</span>
)}
<div className="pointer-events-auto w-9 h-9 bg-black border border-orange-500 rounded-lg flex items-center justify-center shrink-0 ml-3">
<span className="text-orange-500 font-black text-sm leading-none">t.</span>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-end px-5 pt-5 pb-6">
<button
onClick={handleARClick}
disabled={arLoading}
className={`flex items-center gap-2 text-black font-bold text-xs tracking-widest uppercase px-5 py-3 rounded-lg transition-all duration-150 shadow-[0_0_24px_rgba(249,115,22,0.4)]
${arLoading
? 'bg-orange-500/80 cursor-not-allowed'
: 'bg-orange-500 hover:bg-orange-400 active:scale-95'
} ${showHints ? 'min-w-[220px] justify-center' : ''}`}
>
{arLoading && showHints ? (
<>
<div className="w-3.5 h-3.5 rounded-full border-2 border-black/30 border-t-black animate-spin shrink-0" />
<span className="truncate">{AR_HINTS[hintIndex]}</span>
</>
) : arLoading ? (
<>
<div className="w-3.5 h-3.5 rounded-full border-2 border-black/30 border-t-black animate-spin shrink-0" />
View in AR
</>
) : (
<>
<Box size={16} />
View in AR
</>
)}
</button>
</div>
</div>
);
}