Spaces:
Running
Running
| <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 Β· β 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> | |