room360 / index.html
GabrielSalem's picture
Update index.html
f15b9b2 verified
Raw
History Blame Contribute Delete
24.2 kB
<html lang="en"><head><style>body {transition: opacity ease-in 0.2s; }
body[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; }
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Room Scanner</title>
<style>
* { box-sizing: border-box; user-select: none; -webkit-tap-highlight-color: transparent; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; height: 100dvh;
overflow: hidden; background: #000;
font-family: system-ui, -apple-system, sans-serif;
overscroll-behavior: none;
touch-action: none;
}
.screen {
position: fixed; inset: 0;
display: none;
background: #000;
}
.screen.active { display: block; }
/* ── RECORD: fullscreen camera ── */
#previewCam {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
background: #111;
}
.cam-overlay {
position: absolute; inset: 0;
pointer-events: none;
display: flex; flex-direction: column;
align-items: center; justify-content: space-between;
padding: max(16px, env(safe-area-inset-top)) 16px max(120px, env(safe-area-inset-bottom));
}
.cam-top-hint {
background: rgba(0,0,0,0.55);
color: #ffd966; font-weight: 700; font-size: 14px;
padding: 8px 16px; border-radius: 20px;
text-align: center; max-width: 90%;
}
.cam-top-hint.recording { color: #ff5555; animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0.5; } }
.cam-arrow {
font-size: 80px; color: #ffd966;
text-shadow: 0 4px 24px #000;
font-weight: 900; line-height: 1;
opacity: 0;
transition: opacity 0.2s;
}
.cam-arrow.show { opacity: 1; animation: nudge 1.2s ease-in-out infinite; }
@keyframes nudge { 50% { transform: translateX(-14px); } }
.record-fab {
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
width: 88px; height: 88px;
border-radius: 50%;
background: rgba(255, 60, 60, 0.92);
border: 5px solid rgba(255,255,255,0.9);
box-shadow: 0 0 0 6px rgba(255,60,60,0.35), 0 8px 32px rgba(0,0,0,0.5);
cursor: pointer; z-index: 5;
touch-action: none;
transition: transform 0.1s, background 0.15s;
}
.record-fab:active, .record-fab.recording {
transform: translate(-50%, -50%) scale(0.92);
background: rgba(180, 0, 0, 0.95);
box-shadow: 0 0 0 10px rgba(255,60,60,0.5), 0 4px 20px rgba(0,0,0,0.6);
}
.record-fab.recording { animation: recPulse 1s infinite; }
@keyframes recPulse { 50% { box-shadow: 0 0 0 16px rgba(255,60,60,0.25), 0 4px 20px rgba(0,0,0,0.6); } }
/* ── VIEWER: fullscreen canvas ── */
#displayCanvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block; touch-action: none;
}
.viewer-loading {
position: absolute; inset: 0;
display: none; align-items: center; justify-content: center;
background: rgba(0,0,0,0.9); color: #2a9d8f;
font-weight: 700; font-size: 16px; z-index: 4;
}
.viewer-loading.show { display: flex; }
/* hamburger menu */
.menu-btn {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
z-index: 20;
width: 44px; height: 44px;
background: rgba(0,0,0,0.55);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 12px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 5px; cursor: pointer;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.menu-btn span {
display: block; width: 20px; height: 2px;
background: #fff; border-radius: 2px;
transition: transform 0.2s, opacity 0.2s;
}
.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.menu-btn.open span:nth-child(2) { opacity: 0; }
.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.menu-drawer {
position: absolute;
top: 0; left: 0; right: 0;
background: rgba(10,12,16,0.97);
border-bottom: 1px solid #2a9d8f;
padding: max(60px, calc(env(safe-area-inset-top) + 52px)) 20px 24px;
z-index: 15;
transform: translateY(-100%);
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1);
}
.menu-drawer.open { transform: translateY(0); }
.menu-drawer h3 { color: #2a9d8f; font-size: 15px; margin-bottom: 14px; }
.menu-row {
display: flex; gap: 8px; align-items: center;
background: #1e2a2e; border-radius: 12px;
padding: 10px 12px; margin-bottom: 10px;
}
.menu-row input {
flex: 1; background: transparent; border: none;
color: #2a9d8f; font-family: monospace; font-size: 12px;
outline: none; min-width: 0;
}
.menu-row input.pending { color: #888; font-style: italic; }
.copy-btn {
background: #2a9d8f; color: #0a0c10; border: none;
border-radius: 10px; padding: 8px 12px;
font-weight: 700; font-size: 12px; cursor: pointer;
white-space: nowrap;
}
.menu-action {
width: 100%; margin-top: 8px;
background: #1e2a2e; border: 1px solid #2a9d8f;
color: #2a9d8f; border-radius: 20px;
padding: 12px; font-weight: 700; font-size: 14px;
cursor: pointer;
}
.menu-status {
color: #ffd966; font-size: 12px;
text-align: center; margin-bottom: 10px; min-height: 16px;
}
.upload-bar-wrap {
height: 4px; background: #1e2a2e; border-radius: 2px;
overflow: hidden; margin-bottom: 12px; display: none;
}
.upload-bar-wrap.show { display: block; }
.upload-bar { height: 100%; background: #2a9d8f; width: 0%; transition: width 0.3s; }
.drag-hint {
position: absolute;
bottom: max(20px, env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,0.55);
font-size: 12px; text-align: center;
pointer-events: none; z-index: 3;
white-space: nowrap;
}
.cam-error {
position: absolute; bottom: max(100px, env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%);
color: #ff8888; font-size: 12px; text-align: center;
max-width: 90%; z-index: 4;
}
</style>
</head>
<body>
<!-- ═══ RECORD SCREEN ═══ -->
<div id="recordScreen" class="screen active">
<video id="previewCam" autoplay="" playsinline="" muted=""></video>
<div class="cam-overlay">
<div id="camHint" class="cam-top-hint">Stand in center Β· hold button Β· turn left slowly</div>
<div id="camArrow" class="cam-arrow">⟡</div>
<div></div>
</div>
<button id="recordFab" class="record-fab" aria-label="Hold to record"></button>
<div id="camError" class="cam-error"></div>
</div>
<!-- ═══ VIEWER SCREEN ═══ -->
<div id="viewerScreen" class="screen">
<canvas id="displayCanvas"></canvas>
<div id="viewerLoading" class="viewer-loading">Loading your room…</div>
<p class="drag-hint" id="dragHint">↔ spin room &nbsp;Β·&nbsp; ↕ pan up / down</p>
<button id="menuBtn" class="menu-btn" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<div id="menuDrawer" class="menu-drawer">
<div class="upload-bar-wrap" id="uploadWrap"><div class="upload-bar" id="uploadBar"></div></div>
<p class="menu-status" id="menuStatus"></p>
<h3>πŸ”— Embed in your app</h3>
<div class="menu-row">
<input id="shareUrl" readonly="" class="pending" value="Uploading…">
<button class="copy-btn" id="copyUrlBtn">Copy</button>
</div>
<div class="menu-row">
<input id="embedCode" readonly="" class="pending" value="Uploading…">
<button class="copy-btn" id="copyEmbedBtn">Copy iframe</button>
</div>
<button class="menu-action" id="recordAgainBtn">πŸŽ₯ Record again</button>
</div>
</div>
<script type="module">
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const SUPABASE_URL = "https://zvesvqkdkrrwqwvhxeua.supabase.co";
const SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inp2ZXN2cWtka3Jyd3F3dmh4ZXVhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODA3MzIyMzUsImV4cCI6MjA5NjMwODIzNX0.uPnU62d7W6CnEKBcrbtvRtXafDQSA6oAGVQlAeKudSU";
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
(function () {
const recordScreen = document.getElementById('recordScreen');
const viewerScreen = document.getElementById('viewerScreen');
const previewVideo = document.getElementById('previewCam');
const recordFab = document.getElementById('recordFab');
const camHint = document.getElementById('camHint');
const camArrow = document.getElementById('camArrow');
const camError = document.getElementById('camError');
const canvas = document.getElementById('displayCanvas');
const ctx = canvas.getContext('2d');
const viewerLoading = document.getElementById('viewerLoading');
const menuBtn = document.getElementById('menuBtn');
const menuDrawer = document.getElementById('menuDrawer');
const menuStatus = document.getElementById('menuStatus');
const shareUrlInput = document.getElementById('shareUrl');
const embedCodeInput = document.getElementById('embedCode');
const uploadWrap = document.getElementById('uploadWrap');
const uploadBar = document.getElementById('uploadBar');
const recordAgainBtn = document.getElementById('recordAgainBtn');
let mediaStream = null, mediaRecorder = null, recordedChunks = [];
let recordedVideoURL = null, lastShareUrl = null, lastVideoId = null;
let isRecording = false, explorerVideo = null;
let videoWidth = 0, videoHeight = 0, duration = 0, frameRate = 30;
// Smooth viewer state
let targetTime = 0, smoothTime = 0;
let targetPanY = 0, smoothPanY = 0;
let velocityX = 0, velocityY = 0;
let isDragging = false, lastX = 0, lastY = 0;
let animId = 0, menuOpen = false;
function showScreen(name) {
recordScreen.classList.toggle('active', name === 'record');
viewerScreen.classList.toggle('active', name === 'viewer');
if (name === 'viewer') resizeCanvas();
}
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', () => { if (viewerScreen.classList.contains('active')) resizeCanvas(); });
async function applyMaxZoomOut(track) {
if (!track?.getCapabilities) return;
const caps = track.getCapabilities();
const adv = [];
if (caps.zoom) adv.push({ zoom: caps.zoom.min });
if (caps.focusMode?.includes('continuous')) adv.push({ focusMode: 'continuous' });
if (adv.length) try { await track.applyConstraints({ advanced: adv }); } catch {}
}
async function openCamera() {
let ultra = null;
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cams = devices.filter(d => d.kind === 'videoinput' && /back|rear|environment|ultra|wide|0\.5/i.test(d.label));
ultra = cams.find(d => /ultra|0\.5|wide/i.test(d.label));
} catch {}
const constraints = ultra?.deviceId
? { deviceId: { exact: ultra.deviceId }, width: { ideal: 1920 }, height: { ideal: 1080 } }
: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } };
const stream = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false });
await applyMaxZoomOut(stream.getVideoTracks()[0]);
return stream;
}
async function initCamera() {
if (isRecording) return;
try {
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
mediaStream = await openCamera();
previewVideo.srcObject = mediaStream;
await previewVideo.play();
camError.textContent = '';
} catch (err) {
try {
const fb = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
mediaStream = fb;
previewVideo.srcObject = fb;
await previewVideo.play();
await applyMaxZoomOut(fb.getVideoTracks()[0]);
} catch (e2) {
camError.textContent = 'Camera error: ' + (e2.message || 'denied');
recordFab.disabled = true;
}
}
}
function stopCamera() {
mediaStream?.getTracks().forEach(t => t.stop());
mediaStream = null;
previewVideo.srcObject = null;
}
function setRecordingUI(on) {
recordFab.classList.toggle('recording', on);
camHint.classList.toggle('recording', on);
camArrow.classList.toggle('show', on);
camHint.textContent = on ? 'πŸ”΄ TURN LEFT SLOWLY Β· STAY STABLE' : 'Stand in center Β· hold button Β· turn left slowly';
}
function startRecording() {
if (!mediaStream || isRecording) return;
isRecording = true;
recordedChunks = [];
let mime = 'video/webm;codecs=vp9';
if (!MediaRecorder.isTypeSupported(mime)) mime = 'video/webm;codecs=vp8';
if (!MediaRecorder.isTypeSupported(mime)) mime = 'video/webm';
if (!MediaRecorder.isTypeSupported(mime)) mime = 'video/mp4';
try {
mediaRecorder = new MediaRecorder(mediaStream, { mimeType: mime, videoBitsPerSecond: 8000000 });
} catch {
try { mediaRecorder = new MediaRecorder(mediaStream, { mimeType: mime }); }
catch { mediaRecorder = new MediaRecorder(mediaStream); }
}
mediaRecorder.ondataavailable = e => { if (e.data.size) recordedChunks.push(e.data); };
mediaRecorder.onstop = onRecordingStop;
mediaRecorder.start(200);
setRecordingUI(true);
}
function stopRecording() {
if (mediaRecorder?.state === 'recording') mediaRecorder.stop();
}
recordFab.addEventListener('pointerdown', e => { e.preventDefault(); recordFab.setPointerCapture(e.pointerId); startRecording(); });
recordFab.addEventListener('pointerup', e => { e.preventDefault(); stopRecording(); });
recordFab.addEventListener('pointercancel', e => { e.preventDefault(); stopRecording(); });
function makeShortId() {
const c = 'abcdefghijklmnopqrstuvwxyz0123456789';
const b = new Uint8Array(8); crypto.getRandomValues(b);
return Array.from(b, x => c[x % c.length]).join('');
}
async function onRecordingStop() {
isRecording = false;
setRecordingUI(false);
const ext = (mediaRecorder.mimeType || '').includes('mp4') ? 'mp4' : 'webm';
const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType || 'video/webm' });
if (recordedVideoURL) URL.revokeObjectURL(recordedVideoURL);
recordedVideoURL = URL.createObjectURL(blob);
stopCamera();
showScreen('viewer');
viewerLoading.classList.add('show');
closeMenu();
try {
await loadVideo(recordedVideoURL);
viewerLoading.classList.remove('show');
} catch {
viewerLoading.classList.remove('show');
menuStatus.textContent = '⚠️ Preview load failed';
}
uploadInBackground(blob, ext);
}
async function uploadInBackground(blob, ext) {
uploadWrap.classList.add('show');
uploadBar.style.width = '20%';
menuStatus.textContent = '☁️ Uploading…';
shareUrlInput.value = 'Uploading…';
embedCodeInput.value = 'Uploading…';
shareUrlInput.classList.add('pending');
embedCodeInput.classList.add('pending');
try {
const id = makeShortId();
const path = `${id}.${ext}`;
uploadBar.style.width = '50%';
const { error: upErr } = await supabase.storage.from('videos').upload(path, blob, { contentType: blob.type, upsert: false });
if (upErr) throw upErr;
uploadBar.style.width = '80%';
const { error: insErr } = await supabase.from('room_videos').insert({ id, storage_path: path });
if (insErr) throw insErr;
uploadBar.style.width = '100%';
lastVideoId = id;
localStorage.setItem('room360_last_video_id', id);
lastShareUrl = `${location.origin}/v/${id}`;
shareUrlInput.value = lastShareUrl;
embedCodeInput.value = `<iframe src="${lastShareUrl}" width="100%" height="100%" frameborder="0" allowfullscreen style="position:fixed;inset:0;border:0;"></iframe>`;
shareUrlInput.classList.remove('pending');
embedCodeInput.classList.remove('pending');
menuStatus.textContent = 'βœ… Ready β€” tap ☰ to copy link';
setTimeout(() => uploadWrap.classList.remove('show'), 600);
} catch (e) {
menuStatus.textContent = '❌ Upload failed β€” preview still works';
uploadWrap.classList.remove('show');
}
}
function initExplorerVideo() {
if (!explorerVideo) {
explorerVideo = document.createElement('video');
explorerVideo.muted = true;
explorerVideo.playsInline = true;
explorerVideo.preload = 'auto';
}
}
function loadVideo(url) {
initExplorerVideo();
return new Promise((resolve, reject) => {
const done = () => {
videoWidth = explorerVideo.videoWidth;
videoHeight = explorerVideo.videoHeight;
duration = explorerVideo.duration || 0.001;
if (explorerVideo.getVideoPlaybackQuality) {
const f = explorerVideo.getVideoPlaybackQuality().totalVideoFrames;
if (f && duration > 0) frameRate = f / duration;
}
if (frameRate < 1) frameRate = 30;
targetTime = smoothTime = 0;
targetPanY = smoothPanY = 0;
velocityX = velocityY = 0;
explorerVideo.currentTime = 0;
resolve();
};
explorerVideo.addEventListener('loadeddata', done, { once: true });
explorerVideo.addEventListener('error', () => reject(new Error('load fail')), { once: true });
explorerVideo.src = url;
explorerVideo.load();
setTimeout(() => { if (explorerVideo.readyState >= 2) done(); }, 3000);
});
}
function getDrawMetrics() {
// Cover: fill entire screen (bigger output, no letterboxing)
const scale = Math.max(canvas.width / videoWidth, canvas.height / videoHeight);
const drawW = videoWidth * scale;
const drawH = videoHeight * scale;
const maxPanY = Math.max(0, (drawH - canvas.height) / 2);
const drawX = (canvas.width - drawW) / 2;
return { drawW, drawH, maxPanY, drawX };
}
function drawFrame() {
if (!explorerVideo || !videoWidth) return;
const { drawW, drawH, maxPanY, drawX } = getDrawMetrics();
smoothPanY = Math.min(maxPanY, Math.max(-maxPanY, smoothPanY));
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Pure vertical pan β€” frame stays perfectly horizontal, no tilt
const drawY = (canvas.height - drawH) / 2 + smoothPanY;
if (explorerVideo.readyState >= 2) {
ctx.drawImage(explorerVideo, 0, 0, videoWidth, videoHeight, drawX, drawY, drawW, drawH);
}
}
function animate() {
if (!isDragging) {
// smooth inertia
if (Math.abs(velocityX) > 0.2) {
targetTime += velocityX * 0.0008;
targetTime = Math.min(duration, Math.max(0, targetTime));
velocityX *= 0.94;
}
if (Math.abs(velocityY) > 0.2) {
targetPanY += velocityY * 0.35;
velocityY *= 0.94;
}
}
// smooth lerp β€” buttery rotation
const lerp = 0.14;
smoothTime += (targetTime - smoothTime) * lerp;
smoothPanY += (targetPanY - smoothPanY) * lerp;
if (explorerVideo && duration && Math.abs(explorerVideo.currentTime - smoothTime) > 0.02) {
explorerVideo.currentTime = smoothTime;
}
if (viewerScreen.classList.contains('active') && videoWidth) drawFrame();
animId = requestAnimationFrame(animate);
}
animId = requestAnimationFrame(animate);
// ── Touch / drag on viewer ──
canvas.addEventListener('pointerdown', e => {
if (!duration) return;
isDragging = true;
lastX = e.clientX; lastY = e.clientY;
velocityX = velocityY = 0;
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
});
canvas.addEventListener('pointermove', e => {
if (!isDragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
if (Math.abs(dx) > 1) {
targetTime += dx * 0.003;
targetTime = Math.min(duration, Math.max(0, targetTime));
velocityX = dx;
lastX = e.clientX;
}
if (Math.abs(dy) > 1) {
targetPanY += dy;
velocityY = dy;
lastY = e.clientY;
}
e.preventDefault();
});
canvas.addEventListener('pointerup', () => { isDragging = false; });
canvas.addEventListener('pointercancel', () => { isDragging = false; });
// ── Menu ──
function toggleMenu() {
menuOpen = !menuOpen;
menuBtn.classList.toggle('open', menuOpen);
menuDrawer.classList.toggle('open', menuOpen);
}
function closeMenu() {
menuOpen = false;
menuBtn.classList.remove('open');
menuDrawer.classList.remove('open');
}
menuBtn.addEventListener('click', toggleMenu);
function copyText(input) {
if (!input.value || input.classList.contains('pending')) return;
navigator.clipboard.writeText(input.value).then(() => {
menuStatus.textContent = 'πŸ“‹ Copied!';
setTimeout(() => menuStatus.textContent = '', 1500);
});
}
document.getElementById('copyUrlBtn').addEventListener('click', () => copyText(shareUrlInput));
document.getElementById('copyEmbedBtn').addEventListener('click', () => copyText(embedCodeInput));
recordAgainBtn.addEventListener('click', () => {
closeMenu();
showScreen('record');
initCamera();
});
window.addEventListener('beforeunload', e => {
if (isRecording) { e.preventDefault(); e.returnValue = ''; }
});
initCamera();
})();
</script>