Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>ProCalibrated FX Studio</title> | |
| <!-- Font Awesome for Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --primary: #00f2ff; | |
| --secondary: #ff0055; | |
| --bg-dark: #0a0a0f; | |
| --panel-bg: rgba(20, 20, 30, 0.85); | |
| --text-main: #e0e0e0; | |
| --grid-color: rgba(0, 242, 255, 0.1); | |
| } | |
| * { box-sizing: border-box; outline: none; } | |
| body { | |
| margin: 0; | |
| font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| overflow: hidden; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* Header */ | |
| header { | |
| padding: 10px 20px; | |
| background: #000; | |
| border-bottom: 1px solid #333; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| z-index: 100; | |
| } | |
| h1 { font-size: 1.2rem; margin: 0; text-transform: uppercase; letter-spacing: 2px; color: var(--primary); } | |
| .anycoder-link { | |
| font-size: 0.8rem; | |
| color: #888; | |
| text-decoration: none; | |
| } | |
| .anycoder-link:hover { color: var(--primary); } | |
| /* Main Layout */ | |
| .studio-container { | |
| display: grid; | |
| grid-template-columns: 350px 1fr 300px; | |
| flex: 1; | |
| height: calc(100vh - 50px); | |
| } | |
| /* Panels */ | |
| .panel { | |
| background: var(--panel-bg); | |
| border-right: 1px solid #333; | |
| padding: 15px; | |
| overflow-y: auto; | |
| backdrop-filter: blur(10px); | |
| } | |
| .panel-right { | |
| border-left: 1px solid #333; | |
| border-right: none; | |
| } | |
| h2 { | |
| font-size: 0.9rem; | |
| border-bottom: 1px solid #444; | |
| padding-bottom: 5px; | |
| margin-top: 20px; | |
| color: var(--secondary); | |
| } | |
| h2:first-child { margin-top: 0; } | |
| /* Controls */ | |
| .control-group { margin-bottom: 15px; } | |
| label { display: block; font-size: 0.8rem; margin-bottom: 5px; color: #aaa; } | |
| input[type="range"] { | |
| width: 100%; | |
| height: 6px; | |
| background: #333; | |
| border-radius: 3px; | |
| -webkit-appearance: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: var(--primary); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| } | |
| input[type="number"], input[type="text"], input[type="file"] { | |
| width: 100%; | |
| background: #222; | |
| border: 1px solid #444; | |
| color: white; | |
| padding: 5px; | |
| border-radius: 4px; | |
| } | |
| .btn { | |
| background: #333; | |
| color: white; | |
| border: none; | |
| padding: 8px 12px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| width: 100%; | |
| margin-top: 5px; | |
| transition: 0.2s; | |
| } | |
| .btn:hover { background: #444; } | |
| .btn-primary { background: var(--primary); color: #000; font-weight: bold; } | |
| .btn-primary:hover { background: #00dce6; } | |
| /* Viewport / World */ | |
| .viewport { | |
| position: relative; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background: radial-gradient(circle at center, #1a1a2e 0%, #000 100%); | |
| perspective: 1000px; /* For 3D */ | |
| overflow: hidden; | |
| } | |
| /* The World Container (Gyro Target) */ | |
| #world { | |
| width: 640px; | |
| height: 360px; | |
| position: relative; | |
| transform-style: preserve-3d; | |
| transition: transform 0.1s linear; | |
| box-shadow: 0 0 50px rgba(0,0,0,0.8); | |
| } | |
| canvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| } | |
| /* Overlays */ | |
| #rasterOverlay { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background-image: | |
| linear-gradient(var(--grid-color) 1px, transparent 1px), | |
| linear-gradient(90deg, var(--grid-color) 1px, transparent 1px); | |
| background-size: 50px 50px; | |
| pointer-events: none; | |
| z-index: 10; | |
| display: none; | |
| border: 1px solid var(--primary); | |
| } | |
| .hud-info { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| font-family: monospace; | |
| color: var(--primary); | |
| z-index: 20; | |
| text-shadow: 0 0 5px var(--primary); | |
| pointer-events: none; | |
| } | |
| .math-overlay { | |
| position: absolute; | |
| bottom: 20px; | |
| right: 20px; | |
| font-size: 3rem; | |
| font-family: 'Times New Roman', serif; | |
| font-style: italic; | |
| color: rgba(255,255,255,0.2); | |
| z-index: 15; | |
| pointer-events: none; | |
| } | |
| /* BPM Visualizer */ | |
| .bpm-bars { | |
| display: flex; | |
| gap: 2px; | |
| height: 50px; | |
| align-items: flex-end; | |
| margin-top: 10px; | |
| } | |
| .bar { | |
| flex: 1; | |
| background: var(--secondary); | |
| transition: height 0.1s; | |
| } | |
| /* Gyro Controls UI */ | |
| .gyro-data { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| margin-top: 10px; | |
| font-family: monospace; | |
| font-size: 0.8rem; | |
| } | |
| .gyro-val { color: var(--primary); } | |
| /* Responsive */ | |
| @media (max-width: 1000px) { | |
| .studio-container { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 1fr auto; | |
| overflow-y: auto; | |
| } | |
| .panel { height: 300px; border-right: none; border-top: 1px solid #333; } | |
| .viewport { min-height: 300px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1><i class="fas fa-cube"></i> FX Lab</h1> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link">Built with anycoder</a> | |
| </header> | |
| <div class="studio-container"> | |
| <!-- LEFT PANEL: Inputs & Global FX --> | |
| <aside class="panel"> | |
| <h2><i class="fas fa-photo-video"></i> Source Loader</h2> | |
| <div class="control-group"> | |
| <label>Scene 1 (Video/Image)</label> | |
| <input type="file" id="fileA" accept="image/*,video/*"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Scene 2 (Video/Image)</label> | |
| <input type="file" id="fileB" accept="image/*,video/*"> | |
| </div> | |
| <h2><i class="fas fa-cut"></i> Cutter / Timing</h2> | |
| <div class="control-group"> | |
| <label>Scene 1 End Time (sec)</label> | |
| <input type="number" id="cut1End" value="5"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Scene 2 End Time (sec)</label> | |
| <input type="number" id="cut2End" value="10"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Global Time (Manual)</label> | |
| <input type="range" id="timeSlider" min="0" max="20" step="0.1" value="0"> | |
| <button class="btn btn-primary" id="playBtn"><i class="fas fa-play"></i> Play/Pause</button> | |
| </div> | |
| <h2><i class="fas fa-magic"></i> FX Engine</h2> | |
| <div class="control-group"> | |
| <label>Saturation</label> | |
| <input type="range" class="fx-input" data-prop="saturate" min="0" max="300" value="100"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Contrast</label> | |
| <input type="range" class="fx-input" data-prop="contrast" min="0" max="300" value="100"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Brightness (Light)</label> | |
| <input type="range" class="fx-input" data-prop="brightness" min="0" max="200" value="100"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Sepia</label> | |
| <input type="range" class="fx-input" data-prop="sepia" min="0" max="100" value="0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Blur / Dust FX</label> | |
| <input type="range" id="dustAmount" min="0" max="50" value="0"> | |
| </div> | |
| </aside> | |
| <!-- CENTER PANEL: Viewport --> | |
| <section class="viewport" id="viewportContainer"> | |
| <div id="world"> | |
| <!-- HUD --> | |
| <div class="hud-info"> | |
| FPS: <span id="fpsCounter">0</span><br> | |
| <span style="color:var(--secondary)">π³ ≈ <span id="piVal"></span></span> | |
| </div> | |
| <!-- Math Overlay --> | |
| <div class="math-overlay">π³</div> | |
| <!-- Main Rendering Canvas --> | |
| <canvas id="mainCanvas" width="1280" height="720"></canvas> | |
| <!-- Raster Grid --> | |
| <div id="rasterOverlay"></div> | |
| </div> | |
| </section> | |
| <!-- RIGHT PANEL: Calibration, Audio, Gyro --> | |
| <aside class="panel panel-right"> | |
| <h2><i class="fas fa-border-all"></i> Raster & Align</h2> | |
| <div class="control-group"> | |
| <label>Show Raster</label> | |
| <input type="checkbox" id="toggleRaster"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Offset X (Pan)</label> | |
| <input type="range" id="alignX" min="-200" max="200" value="0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Offset Y (Tilt)</label> | |
| <input type="range" id="alignY" min="-200" max="200" value="0"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Zoom / Scale</label> | |
| <input type="range" id="alignZoom" min="50" max="200" value="100"> | |
| </div> | |
| <h2><i class="fas fa-music"></i> Audio & BPM</h2> | |
| <div class="control-group"> | |
| <label>Load Audio</label> | |
| <input type="file" id="audioFile" accept="audio/*"> | |
| </div> | |
| <div class="control-group"> | |
| <label>BPM Value</label> | |
| <input type="number" id="bpmInput" value="120"> | |
| </div> | |
| <div class="bpm-bars" id="visualizer"> | |
| <!-- Bars generated by JS --> | |
| </div> | |
| <h2><i class="fas fa-mobile-alt"></i> Gyro Control</h2> | |
| <div class="control-group"> | |
| <button class="btn btn-primary" id="reqPermission"><i class="fas fa-broadcast-tower"></i> Init Gyro (iOS)</button> | |
| <div class="gyro-data"> | |
| <div>X: <span id="hudX" class="gyro-val">0</span>°</div> | |
| <div>Y: <span id="hudY" class="gyro-val">0</span>°</div> | |
| </div> | |
| <label style="margin-top:10px">Multiplier</label> | |
| <input type="range" id="speedMult" min="0.1" max="5" step="0.1" value="1"> | |
| </div> | |
| <div class="control-group"> | |
| <button class="btn" onclick="toggleLock('x')" id="lockXBtn">X-Achse</button> | |
| <button class="btn" onclick="toggleLock('y')" id="lockYBtn">Y-Achse</button> | |
| </div> | |
| </aside> | |
| </div> | |
| <!-- LOGIC --> | |
| <script> | |
| /** | |
| * MATH CONSTANTS & SETUP | |
| */ | |
| const PI_CUBED = Math.pow(Math.PI, 3); // ~31.006 | |
| document.getElementById('piVal').innerText = PI_CUBED.toFixed(4); | |
| // Canvas Setup | |
| const canvas = document.getElementById('mainCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const world = document.getElementById('world'); | |
| // State | |
| const state = { | |
| isPlaying: false, | |
| currentTime: 0, | |
| cut1End: 5, | |
| cut2End: 10, | |
| mediaA: null, // { type: 'img'|'video', element: DOMNode } | |
| mediaB: null, | |
| fx: { saturate: 100, contrast: 100, brightness: 100, sepia: 0 }, | |
| raster: { show: false, x: 0, y: 0, zoom: 1 }, | |
| gyro: { | |
| currentX: 0, currentY: 0, | |
| baseX: null, baseY: null, | |
| lockedX: false, lockedY: false, | |
| fixedValX: 0, fixedValY: 0, | |
| multiplier: 1 | |
| }, | |
| particles: [], | |
| audioCtx: null, | |
| audioSource: null, | |
| audioBuffer: null, | |
| analyser: null, | |
| bpm: 120 | |
| }; | |
| // Generate Visualizer Bars | |
| const visualizerContainer = document.getElementById('visualizer'); | |
| for(let i=0; i<16; i++){ | |
| let bar = document.createElement('div'); | |
| bar.className = 'bar'; | |
| bar.style.height = '10%'; | |
| visualizerContainer.appendChild(bar); | |
| } | |
| const bars = document.querySelectorAll('.bar'); | |
| /** | |
| * 1. CORE RENDER LOOP | |
| */ | |
| let lastTime = 0; | |
| function animate(timestamp) { | |
| if (!lastTime) lastTime = timestamp; | |
| const delta = timestamp - lastTime; | |
| lastTime = timestamp; | |
| // FPS | |
| document.getElementById('fpsCounter').innerText = Math.round(1000/delta); | |
| // Time Management | |
| if (state.isPlaying) { | |
| state.currentTime += delta / 1000; | |
| document.getElementById('timeSlider').value = state.currentTime; | |
| // Loop logic | |
| if (state.currentTime > state.cut2End) state.currentTime = 0; | |
| } | |
| // Clear Canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| // Draw Media (Cutter Logic) | |
| // Apply Global FX Filters via Context Filter (Modern Browsers) | |
| ctx.filter = `saturate(${state.fx.saturate}%) contrast(${state.fx.contrast}%) brightness(${state.fx.brightness}%) sepia(${state.fx.sepia}%)`; | |
| // Logic: If time < cut1, show A. Else show B. | |
| let activeMedia = null; | |
| if (state.currentTime < state.cut1End && state.mediaA) { | |
| activeMedia = state.mediaA; | |
| } else if (state.currentTime >= state.cut1End && state.currentTime < state.cut2End && state.mediaB) { | |
| activeMedia = state.mediaB; | |
| } else if (state.mediaA) { | |
| activeMedia = state.mediaA; // Fallback or loop | |
| } | |
| if (activeMedia) { | |
| // Draw active media | |
| if (activeMedia.type === 'video') { | |
| if (!activeMedia.element.paused && !activeMedia.element.ended) { | |
| // Sync video current time if playing, | |
| // but in this app, 'currentTime' controls the playback position of the "Editor" | |
| // For simplicity in this playground, we let the video play naturally or seek. | |
| // To sync perfectly: | |
| // activeMedia.element.currentTime = state.currentTime; // Force seek (may stutter) | |
| } | |
| // Center and Scale based on alignment | |
| const dw = canvas.width * state.raster.zoom; | |
| const dh = canvas.height * state.raster.zoom; | |
| const dx = (canvas.width - dw) / 2 + state.raster.x; | |
| const dy = (canvas.height - dh) / 2 + state.raster.y; | |
| ctx.drawImage(activeMedia.element, dx, dy, dw, dh); | |
| } else if (activeMedia.type === 'img') { | |
| const dw = canvas.width * state.raster.zoom; | |
| const dh = canvas.height * state.raster.zoom; | |
| const dx = (canvas.width - dw) / 2 + state.raster.x; | |
| const dy = (canvas.height - dh) / 2 + state.raster.y; | |
| ctx.drawImage(activeMedia.element, dx, dy, dw, dh); | |
| } | |
| } else { | |
| // Placeholder Text | |
| ctx.fillStyle = "#333"; | |
| ctx.font = "20px Arial"; | |
| ctx.fillText("No Media Loaded", 50, 50); | |
| } | |
| ctx.filter = 'none'; // Reset filter for particles | |
| // FX: Dust / Particles | |
| updateParticles(delta); | |
| // FX: Audio Visualizer (Overlay on Canvas) | |
| drawAudioVisualizer(); | |
| requestAnimationFrame(animate); | |
| } | |
| /** | |
| * 2. PARTICLE SYSTEM (Dust/FX) | |
| */ | |
| function initParticles() { | |
| for(let i=0; i<50; i++) { | |
| state.particles.push({ | |
| x: Math.random() * canvas.width, | |
| y: Math.random() * canvas.height, | |
| vx: (Math.random() - 0.5) * 0.5, | |
| vy: (Math.random() - 0.5) * 0.5, | |
| size: Math.random() * 2, | |
| alpha: Math.random() | |
| }); | |
| } | |
| } | |
| initParticles(); | |
| function updateParticles(delta) { | |
| // Check dust slider | |
| const dustAmount = document.getElementById('dustAmount').value; | |
| state.particles.forEach(p => { | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| // Wrap around | |
| if(p.x < 0) p.x = canvas.width; | |
| if(p.x > canvas.width) p.x = 0; | |
| if(p.y < 0) p.y = canvas.height; | |
| if(p.y > canvas.height) p.y = 0; | |
| // Draw | |
| ctx.fillStyle = `rgba(200, 200, 255, ${p.alpha * (dustAmount/100)})`; | |
| ctx.beginPath(); | |
| ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); | |
| ctx.fill(); | |
| }); | |
| } | |
| /** | |
| * 3. AUDIO & BPM | |
| */ | |
| document.getElementById('audioFile').addEventListener('change', function(e) { | |
| const file = e.target.files[0]; | |
| if(!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(ev) { | |
| if(!state.audioCtx) { | |
| state.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| state.audioCtx.decodeAudioData(ev.target.result, function(buffer) { | |
| state.audioBuffer = buffer; | |
| playAudio(); | |
| }); | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| }); | |
| function playAudio() { | |
| if(state.audioSource) state.audioSource.stop(); | |
| state.audioSource = state.audioCtx.createBufferSource(); | |
| state.audioSource.buffer = state.audioBuffer; | |
| state.analyser = state.audioCtx.createAnalyser(); | |
| state.analyser.fftSize = 32; | |
| state.audioSource.connect(state.analyser); | |
| state.analyser.connect(state.audioCtx.destination); | |
| state.audioSource.start(0); | |
| } | |
| function drawAudioVisualizer() { | |
| if (!state.analyser) return; | |
| const bufferLength = state.analyser.frequencyBinCount; | |
| const dataArray = new Uint8Array(bufferLength); | |
| state.analyser.getByteFrequencyData(dataArray); | |
| // Sync bars to DOM | |
| for(let i=0; i<16; i++) { | |
| // Average chunks of frequency data | |
| let sum = 0; | |
| const step = Math.floor(bufferLength / 16); | |
| for(let j=0; j<step; j++) { | |
| sum += dataArray[i*step + j]; | |
| } | |
| const avg = sum / step; | |
| // Scale based on BPM pulse (fake math for visual flair) | |
| const height = (avg / 255) * 100; | |
| bars[i].style.height = `${height}%`; | |
| // Colorize on beat (simplified) | |
| if(height > 80) bars[i].style.background = 'white'; | |
| else bars[i].style.background = 'var(--secondary)'; | |
| } | |
| } | |
| /** | |
| * 4. MEDIA LOADER | |
| */ | |
| function handleFileSelect(e, slot) { | |
| const file = e.target.files[0]; | |
| if(!file) return; | |
| const url = URL.createObjectURL(file); | |
| const type = file.type.startsWith('video') ? 'video' : 'img'; | |
| let element; | |
| if(type === 'video') { | |
| element = document.createElement('video'); | |
| element.src = url; | |
| element.muted = true; | |
| element.loop = true; | |
| // Note: We don't play() globally here to allow manual scrubbing, | |
| // but we could. | |
| } else { | |
| element = new Image(); | |
| element.src = url; | |
| } | |
| element.onloadeddata = () => { | |
| state[slot] = { type, element }; | |
| }; | |
| } | |
| document.getElementById('fileA').addEventListener('change', (e) => handleFileSelect(e, 'mediaA')); | |
| document.getElementById('fileB').addEventListener('change', (e) => handleFileSelect(e, 'mediaB')); | |
| /** | |
| * 5. CONTROLS WIRING | |
| */ | |
| document.getElementById('playBtn').addEventListener('click', () => { | |
| state.isPlaying = !state.isPlaying; | |
| // If video exists, we could sync play/pause | |
| }); | |
| document.querySelectorAll('.fx-input').forEach(input => { | |
| input.addEventListener('input', (e) => { | |
| state.fx[e.target.dataset.prop] = e.target.value; | |
| }); | |
| }); | |
| document.getElementById('timeSlider').addEventListener('input', (e) => { | |
| state.currentTime = parseFloat(e.target.value); | |
| // Seek video if exists | |
| if(state.mediaA && state.mediaA.type === 'video') state.mediaA.element.currentTime = state.currentTime; | |
| }); | |
| document.getElementById('toggleRaster').addEventListener('change', (e) => { | |
| document.getElementById('rasterOverlay').style.display = e.target.checked ? 'block' : 'none'; | |
| }); | |
| document.getElementById('alignX').addEventListener('input', (e) => state.raster.x = parseInt(e.target.value)); | |
| document.getElementById('alignY').addEventListener('input', (e) => state.raster.y = parseInt(e.target.value)); | |
| document.getElementById('alignZoom').addEventListener('input', (e) => state.raster.zoom = parseInt(e.target.value)/100); | |
| /** | |
| * 6. GYRO INTEGRATION (Based on prompt snippet logic) | |
| */ | |
| const speedInput = document.getElementById('speedMult'); | |
| const hudX = document.getElementById('hudX'); | |
| const hudY = document.getElementById('hudY'); | |
| speedInput.addEventListener('input', (e) => { | |
| state.gyro.multiplier = parseFloat(e.target.value); | |
| }); | |
| function toggleLock(axis) { | |
| const btn = document.getElementById(axis === 'x' ? 'lockXBtn' : 'lockYBtn'); | |
| if(axis === 'x') { | |
| state.gyro.lockedX = !state.gyro.lockedX; | |
| if(state.gyro.lockedX) { | |
| state.gyro.fixedValX = state.gyro.currentX; | |
| btn.classList.add('locked'); | |
| btn.style.borderColor = "var(--secondary)"; | |
| } else { | |
| btn.classList.remove('locked'); | |
| btn.style.borderColor = "#444"; | |
| } | |
| } else { | |
| state.gyro.lockedY = !state.gyro.lockedY; | |
| if(state.gyro.lockedY) { | |
| state.gyro.fixedValY = state.gyro.currentY; | |
| btn.classList.add('locked'); | |
| btn.style.borderColor = "var(--secondary)"; | |
| } else { | |
| btn.classList.remove('locked'); | |
| btn.style.borderColor = "#444"; | |
| } | |
| } | |
| } | |
| function handleOrientation(event) { | |
| const xRaw = event.beta || 0; // X axis (beta) | |
| const yRaw = event.gamma || 0; // Y axis (gamma) | |
| if (state.gyro.baseX === null) { | |
| state.gyro.baseX = xRaw; | |
| state.gyro.baseY = yRaw; | |
| } | |
| let newX = (xRaw - state.gyro.baseX) * state.gyro.multiplier; | |
| let newY = (yRaw - state.gyro.baseY) * state.gyro.multiplier; | |
| // Logic check: locked or update | |
| if (!state.gyro.lockedX) state.gyro.currentX = newX; | |
| else state.gyro.currentX = state.gyro.fixedValX; | |
| if (!state.gyro.lockedY) state.gyro.currentY = newY; | |
| else state.gyro.currentY = state.gyro.fixedValY; | |
| updateGyroView(); | |
| } | |
| // Mouse fallback for desktop testing | |
| document.addEventListener('mousemove', (e) => { | |
| if(!window.DeviceOrientationEvent || !('ontouchstart' in window)) { | |
| // Desktop simulation | |
| const xPct = (e.clientX / window.innerWidth) - 0.5; | |
| const yPct = (e.clientY / window.innerHeight) - 0.5; | |
| // Only update if not locked (simulated logic) | |
| // Simplified for desktop demo | |
| if(!state.gyro.lockedX) state.gyro.currentX = yPct * 180 * state.gyro.multiplier; | |
| if(!state.gyro.lockedY) state.gyro.currentY = xPct * 180 * state.gyro.multiplier; | |
| updateGyroView(); | |
| } | |
| }); | |
| function updateGyroView() { | |
| // Apply 3D rotation to the World Div | |
| // rotateX for Beta (X axis tilt), rotateY for Gamma (Y axis pan) | |
| world.style.transform = `rotateX(${-state.gyro.currentX}deg) rotateY(${state.gyro.currentY}deg)`; | |
| hudX.innerText = Math.round(state.gyro.currentX); | |
| hudY.innerText = Math.round(state.gyro.currentY); | |
| } | |
| // Permission Request | |
| document.getElementById('reqPermission').addEventListener('click', () => { | |
| if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') { | |
| DeviceOrientationEvent.requestPermission() | |
| .then(response => { | |
| if (response === 'granted') { | |
| window.addEventListener('deviceorientation', handleOrientation); | |
| document.getElementById('reqPermission').style.display = 'none'; | |
| } | |
| }) | |
| .catch(console.error); | |
| } else { | |
| window.addEventListener('deviceorientation', handleOrientation); | |
| document.getElementById('reqPermission').style.display = 'none'; | |
| } | |
| }); | |
| // Start Loop | |
| requestAnimationFrame(animate); | |
| </script> | |
| </body> | |
| </html> |