Files
drohne/viewer360.html

967 lines
36 KiB
HTML
Raw Permalink Normal View History

2026-01-05 11:36:58 +00:00
<!DOCTYPE html>
<!--
Lightweight browser based viewer for 360 degree images and videos.
Created by Ben Egan: https://github.com/ProGamerGov
Controls
| Action | How to Use |
| ------------------ | -------------------------------------------|
| Navigate View | Left-click + drag or touch + drag |
| Zoom | Mouse wheel or pinch gesture |
| Fullscreen | Browser fullscreen and VR headset control |
| Stereo Toggle | Bottom-left "Stereo" button |
| Screenshot | Camera icon at bottom center |
| Upload/Reset Media | "Upload" button below controls |
| Play/Pause Video | Play/pause button on video controls |
| Seek in Video | Use the timeline slider |
Additonal info
- Stereo Toggle: Switch between monoscopic and stereo images (mono/top-bottom/side-by-side)
- Upload/Reset Media: New media can be uploaded via drag and drop at any time.
Loading from external URLs
- Images and videos can be loaded from other websites by appending '?url=' or '?src=' to the viewer URL, followed by the direct media link.
- Example: https://progamergov.github.io/html-360-viewer?url=https://example.com/example_image.jpg
- This only works when the viewer is hosted via a local or remote web server (not directly opened as a file).
- See here for more details: https://aframe.io/docs/1.7.0/introduction/installation.html#use-a-local-server
- You can also provide the mono/stereo viewing mode that should be used when loading the image.
- To specify the viewing mode to use, append '&stereo=0' for monoscopic (default), '&stereo=1' for up/down stereo, and '&stereo=2' for right/left stereo.
- Example: https://progamergov.github.io/html-360-viewer?url=https://example.com/example_image.jpg&stereo=0
BibTeX
@misc{egan2025html360viewer,
title={Browser-Based Viewer for 360 Images and Videos},
author={Egan, Ben},
year={2025},
publisher={GitHub},
howpublished={\url{https://github.com/ProGamerGov/html-360-viewer}}
}
MIT License
Copyright (c) 2025 Ben Egan
-->
<html lang="en">
<head>
<!-- Primary Meta Tags -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="title" content="Browser-Based Viewer for 360° Images and Videos">
<meta name="description" content="A lightweight, browser-based viewer for interactive 360° panoramas and videos. Supports monoscopic and stereo formats, zoom, and screenshot.">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://progamergov.github.io/html-360-viewer">
<meta property="og:title" content="Browser-Based Viewer for 360° Images and Videos">
<meta property="og:description" content="A lightweight, browser-based viewer for interactive 360° panoramas and videos. Supports monoscopic and stereo formats, zoom, and screenshot.">
<meta property="og:image" content="https://raw.githubusercontent.com/ProGamerGov/html-360-viewer/main/examples/example_image.png">
<!-- Page Title -->
<title>360° Panorama & Video Viewer</title>
<!-- A-Frame -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/aframe/1.7.0/aframe.min.js"></script>
<!-- Material Symbols for icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
<style>
/* --- Layout & Typography --- */
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
overflow: hidden;
touch-action: pan-x pan-y; /* disable pinch-zoom */
}
#container {
width: 100%;
height: 100vh;
position: relative;
background: #000;
}
/* --- Drop area --- */
#dropArea {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #f5f5f5;
border: 3px dashed #ccc;
z-index: 10;
transition: 0.2s ease all;
}
#dropArea.hidden { display: none; }
#dropArea h2 { margin-bottom: 12px; color: #333; }
#fileInput { display: none; }
#selectButton {
padding: 12px 24px;
background: #4285f4;
border: none;
border-radius: 4px;
color: #fff;
font-size: 16px;
cursor: pointer;
}
#selectButton:hover { background: #3367d6; }
/* --- A-Frame container --- */
#aframeContainer {
position: absolute;
inset: 0;
}
/* --- Global controls wrapper --- */
#controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: none;
text-align: center;
user-select: none;
flex-direction: column;
align-items: center;
}
.control-button {
padding: 6px 12px;
background: rgba(0,0,0,0.5);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-bottom: 12px;
margin-right: 8px;
}
.control-button:hover { background: rgba(0,0,0,0.7); }
.control-button.active { background: #4285f4; }
.control-row {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
/* --- Modern video controls --- */
#videoControls {
display: none;
flex-direction: row;
align-items: center;
gap: 12px;
background: rgba(0,0,0,0.6);
padding: 10px 14px;
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0,0,0,0.35);
margin-bottom: 16px;
}
#playPauseButton {
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
background: #4285f4;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
#playPauseButton:hover { background: #3367d6; }
/* Slider styling */
#videoSlider {
-webkit-appearance: none;
width: 320px;
height: 6px;
background: #ddd;
border-radius: 3px;
outline: none;
}
#videoSlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4285f4;
cursor: pointer;
border: none;
}
#videoSlider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #4285f4;
cursor: pointer;
border: none;
}
/* --- Loading indicator & zoom info --- */
#loadingIndicator {
position: absolute;
inset: 0;
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 30;
background: rgba(255,255,255,0.6);
backdrop-filter: blur(3px);
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0,0,0,0.15);
border-left-color: #4285f4;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin { 0% {transform: rotate(0);} 100% {transform: rotate(360deg);} }
#zoomInfo {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0,0,0,0.55);
color: #fff;
padding: 6px 10px;
border-radius: 4px;
font-size: 14px;
z-index: 20;
display: none;
}
/* --- Stereo Button --- */
#stereoButton {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 20;
padding: 6px 10px;
background: rgba(0,0,0,0.6);
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
display: none;
}
#stereoButton:hover { background: rgba(0,0,0,0.8); }
#stereoButton.active { background: rgba(66,133,244,0.8); }
/* --- Upload Button --- */
#resetButton {
padding: 8px 16px;
background: rgba(0,0,0,0.5);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
z-index: 25;
display: none;
margin-top: 10px;
height: 36px;
}
#resetButton:hover { background: rgba(0,0,0,0.7); }
/* --- Screenshot Button --- */
#screenshotButton {
padding: 0;
background: rgba(0,0,0,0.5);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
z-index: 25;
display: none;
margin-left: 8px;
margin-top: 10px;
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
#screenshotButton:hover { background: rgba(0,0,0,0.7); }
/* --- Bottom buttons container --- */
#bottomButtonsContainer {
display: flex;
justify-content: center;
align-items: center;
}
/* --- Screenshot flash effect --- */
#screenshotFlash {
position: absolute;
inset: 0;
background: white;
opacity: 0;
z-index: 40;
pointer-events: none;
}
/* --- Screenshot feedback --- */
#screenshotFeedback {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.7);
color: white;
padding: 12px 20px;
border-radius: 6px;
font-size: 14px;
z-index: 45;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
/* --- Media drop overlay --- */
#globalDropOverlay {
position: absolute;
inset: 0;
background: rgba(18, 85, 204, 0.7);
border: 5px dashed #fff;
z-index: 50;
display: none;
justify-content: center;
align-items: center;
color: white;
font-size: 24px;
backdrop-filter: blur(2px);
}
#globalDropOverlay p {
background: rgba(0, 0, 0, 0.5);
padding: 15px 30px;
border-radius: 8px;
text-align: center;
}
/* Material icons styles */
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: 24px;
}
/* ------------- Mobile Device Override ------------- */
@media (max-width: 900px) {
/* lift control bar */
#controls { bottom: 80px !important; }
/* lift the Stereo toggle to the same line */
#stereoButton { bottom: 80px !important; }
/* pull A-Frames VR / fullscreen button up and over everything */
.a-enter-vr-button {
bottom: 80px !important;
z-index: 1000 !important;
}
}
/* ------------- Hide Image Highlighting ------------- */
::selection {
background: transparent;
}
::moz-selection {
background: transparent;
}
</style>
</head>
<body>
<div id="container">
<div id="dropArea">
<h2>Select or Drag & Drop a 360° Media</h2>
<p>Supported formats: All images, MP4, WebM</p>
<input type="file" id="fileInput" accept="image/*, video/mp4, video/webm" />
<button id="selectButton">Select Media</button>
</div>
<div id="aframeContainer"></div>
<div id="controls">
<div id="videoControls">
<button id="playPauseButton"><span class="material-symbols-outlined">pause</span></button>
<input type="range" id="videoSlider" min="0" max="100" value="0" step="0.1" />
</div>
<div id="bottomButtonsContainer">
<button id="resetButton" class="control-button">Upload</button>
<button id="screenshotButton" class="control-button" title="Take screenshot">
<span class="material-symbols-outlined">photo_camera</span>
</button>
</div>
</div>
<button id="stereoButton">Stereo</button>
<div id="loadingIndicator">
<div class="spinner"></div>
<p>Loading media…</p>
</div>
<div id="zoomInfo">Zoom: 80°</div>
<!-- Screenshot flash animation -->
<div id="screenshotFlash"></div>
<!-- Screenshot feedback -->
<div id="screenshotFeedback">Screenshot saved!</div>
<!-- Global drop overlay -->
<div id="globalDropOverlay">
<p>Drop 360° media to replace current view</p>
</div>
</div>
<script>
// Extract media URL from query parameters if present
function getMediaUrlFromParams() {
const urlParams = new URLSearchParams(window.location.search);
const mediaUrl = urlParams.get('url') || urlParams.get('src');
const stereoParam = urlParams.get('stereo');
let stereoMode = 0; // Default: mono
if (stereoParam) {
const stereoLower = stereoParam.toLowerCase();
switch (stereoLower) {
case 'tb':
case 'vertical':
case '1':
stereoMode = 1;
break;
case 'lr':
case 'horizontal':
case '2':
stereoMode = 2;
break;
default:
stereoMode = 0; // mono
}
}
return {
url: mediaUrl,
stereoMode: stereoMode
};
}
document.addEventListener('DOMContentLoaded', () => {
/* ------------------ DOM references ------------------ */
const elements = {
dropArea: document.getElementById('dropArea'),
fileInput: document.getElementById('fileInput'),
selectButton: document.getElementById('selectButton'),
aframeContainer: document.getElementById('aframeContainer'),
controls: document.getElementById('controls'),
resetButton: document.getElementById('resetButton'),
stereoButton: document.getElementById('stereoButton'),
videoControls: document.getElementById('videoControls'),
playPauseButton: document.getElementById('playPauseButton'),
videoSlider: document.getElementById('videoSlider'),
loadingIndicator: document.getElementById('loadingIndicator'),
zoomInfo: document.getElementById('zoomInfo'),
screenshotButton: document.getElementById('screenshotButton'),
screenshotFlash: document.getElementById('screenshotFlash'),
screenshotFeedback: document.getElementById('screenshotFeedback'),
globalDropOverlay: document.getElementById('globalDropOverlay'),
container: document.getElementById('container')
};
/* ------------------ App state ------------------ */
const state = {
scene: null,
videoElement: null,
isPlaying: true,
currentFov: 80,
currentMedia: null,
isMediaImage: false,
stereoMode: 0,
isDraggingFile: false
};
/* ------------------ Helpers ------------------ */
const helpers = {
showLoading: (flag) => {
elements.loadingIndicator.style.display = flag ? 'flex' : 'none';
},
updateFov: (delta) => {
state.currentFov = Math.min(179, Math.max(1, state.currentFov + delta));
const cam = document.querySelector('#mainCamera');
if (cam) cam.setAttribute('camera', 'fov', state.currentFov);
elements.zoomInfo.textContent = `Zoom: ${Math.round(state.currentFov)}°`;
elements.zoomInfo.style.background = 'rgba(66,133,244,0.7)';
setTimeout(() => elements.zoomInfo.style.background = 'rgba(0,0,0,0.55)', 300);
},
updatePlayPauseIcon: () => {
elements.playPauseButton.innerHTML = state.isPlaying ?
'<span class="material-symbols-outlined">pause</span>' :
'<span class="material-symbols-outlined">play_arrow</span>';
},
preventDefaults: (e) => {
e.preventDefault();
e.stopPropagation();
},
updateStereoButton: () => {
if (state.stereoMode > 0) {
elements.stereoButton.classList.add('active');
} else {
elements.stereoButton.classList.remove('active');
}
},
updateSlider: () => {
if (!state.videoElement || !state.videoElement.duration) return;
const val = (state.videoElement.currentTime / state.videoElement.duration) * 100;
elements.videoSlider.value = val;
},
dist: (t) => {
return Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
},
showGlobalDropOverlay: (show) => {
elements.globalDropOverlay.style.display = show ? 'flex' : 'none';
}
};
/* ------------------ Media handling ------------------ */
const mediaHandlers = {
processFile: (file) => {
helpers.showLoading(true);
state.currentFov = 80;
elements.zoomInfo.textContent = 'Zoom: 80°';
elements.videoControls.style.display = 'none';
elements.stereoButton.style.display = 'none';
state.stereoMode = 0;
helpers.updateStereoButton();
const reader = new FileReader();
reader.onload = (e) => {
const url = e.target.result;
state.currentMedia = url;
state.isMediaImage = file.type.startsWith('image/');
state.isMediaImage ? mediaHandlers.createImagePanorama(url) : mediaHandlers.createVideoPanorama(url);
};
reader.readAsDataURL(file);
},
processStereoImage: (src, callback) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (state.stereoMode === 1) { // Vertical (top/bottom)
canvas.width = img.width;
canvas.height = img.height / 2;
ctx.drawImage(img, 0, 0, img.width, img.height / 2, 0, 0, canvas.width, canvas.height);
} else if (state.stereoMode === 2) { // Horizontal (left/right)
canvas.width = img.width / 2;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, img.width / 2, img.height, 0, 0, canvas.width, canvas.height);
} else {
// Non-stereo, use full image
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
}
callback(canvas.toDataURL('image/jpeg', 0.95));
};
img.onerror = () => {
helpers.showLoading(false);
alert('Error loading image.');
};
img.src = src;
},
createImagePanorama: (src) => {
helpers.showLoading(true);
mediaHandlers.processStereoImage(src, (processedSrc) => {
interfaceHandlers.cleanupScene();
state.scene = document.createElement('a-scene');
state.scene.setAttribute('embedded','');
state.scene.setAttribute('xr-mode-ui','enabled: true');
const sky = document.createElement('a-sky');
sky.setAttribute('src', processedSrc);
sky.setAttribute('material', 'npot: true');
sky.setAttribute('rotation', '0 -90 0');
sky.setAttribute('geometry', 'segmentsHeight: 64; segmentsWidth: 128');
const camera = document.createElement('a-entity');
camera.setAttribute('camera', 'fov: 80');
camera.setAttribute('look-controls', 'reverseMouseDrag: true');
camera.setAttribute('wasd-controls', 'enabled: false');
camera.setAttribute('position', '0 1.6 0');
camera.setAttribute('id', 'mainCamera');
state.scene.appendChild(sky);
state.scene.appendChild(camera);
elements.aframeContainer.appendChild(state.scene);
sky.addEventListener('loaded', () => helpers.showLoading(false));
sky.addEventListener('error', () => {
helpers.showLoading(false);
alert('Error loading panorama image.');
});
elements.dropArea.classList.add('hidden');
elements.controls.style.display = 'flex';
elements.zoomInfo.style.display = 'block';
elements.stereoButton.style.display = 'block';
elements.resetButton.style.display = 'block';
elements.screenshotButton.style.display = 'block';
});
},
createVideoPanorama: (src) => {
interfaceHandlers.cleanupScene();
state.scene = document.createElement('a-scene');
state.scene.setAttribute('embedded','');
state.scene.setAttribute('xr-mode-ui','enabled: true');
// Assets
const assets = document.createElement('a-assets');
state.scene.appendChild(assets);
state.videoElement = document.createElement('video');
state.videoElement.setAttribute('id', 'videoAsset');
state.videoElement.setAttribute('src', src);
state.videoElement.setAttribute('crossorigin', 'anonymous');
state.videoElement.setAttribute('playsinline', '');
state.videoElement.setAttribute('webkit-playsinline', '');
state.videoElement.setAttribute('preload', 'metadata');
state.videoElement.setAttribute('loop', '');
assets.appendChild(state.videoElement);
// Videosphere
const vidsphere = document.createElement('a-videosphere');
vidsphere.setAttribute('src', '#videoAsset');
vidsphere.setAttribute('rotation', '0 -90 0');
state.scene.appendChild(vidsphere);
// Camera
const camera = document.createElement('a-entity');
camera.setAttribute('camera', 'fov: 80');
camera.setAttribute('look-controls', 'reverseMouseDrag: true');
camera.setAttribute('wasd-controls', 'enabled: false');
camera.setAttribute('position', '0 1.6 0');
camera.setAttribute('id', 'mainCamera');
state.scene.appendChild(camera);
elements.aframeContainer.appendChild(state.scene);
// UI state
elements.dropArea.classList.add('hidden');
elements.controls.style.display = 'flex';
elements.videoControls.style.display = 'flex';
elements.zoomInfo.style.display = 'block';
elements.stereoButton.style.display = 'none'; // Hide stereo button for videos
elements.resetButton.style.display = 'block';
elements.screenshotButton.style.display = 'block';
state.isPlaying = true;
helpers.updatePlayPauseIcon();
// Event listeners
state.videoElement.addEventListener('loadedmetadata', () => {
helpers.showLoading(false);
elements.videoSlider.value = 0;
state.videoElement.play().catch(() => {}); // autoplay may require user gesture
});
state.videoElement.addEventListener('timeupdate', helpers.updateSlider);
},
// Load media from URL if provided
loadMediaFromUrl: (url, initialStereoMode = 0) => {
helpers.showLoading(true);
// Set the stereo mode before loading
state.stereoMode = initialStereoMode;
helpers.updateStereoButton();
// Determine if it's a video based on extension
const lowerUrl = url.toLowerCase();
const isVideo = ['.mp4', '.webm'].some(ext => lowerUrl.endsWith(ext));
if (isVideo) {
// Handle video (stereo mode not supported for videos in current implementation)
state.currentMedia = url;
state.isMediaImage = false;
state.stereoMode = 0; // Reset stereo mode for videos
mediaHandlers.createVideoPanorama(url);
} else {
// Handle image with stereo support
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => {
state.currentMedia = url;
state.isMediaImage = true;
mediaHandlers.createImagePanorama(url);
};
img.onerror = () => {
helpers.showLoading(false);
alert('Unsupported media type or inaccessible URL');
};
img.src = url;
}
}
};
/* ------------------ Interface handlers ------------------ */
const interfaceHandlers = {
takeScreenshot: () => {
if (!state.scene) return;
// Temporarily hide UI elements
const elementsToHide = [
elements.zoomInfo,
elements.controls,
elements.stereoButton
];
// Store original display states
const originalDisplays = elementsToHide.map(el => el.style.display);
// Hide elements
elementsToHide.forEach(el => el.style.display = 'none');
// Let the render update (wait for next frame)
requestAnimationFrame(() => {
// Take screenshot of canvas
const canvas = document.querySelector('canvas');
if (!canvas) {
// Restore UI and return
elementsToHide.forEach((el, i) => el.style.display = originalDisplays[i]);
return;
}
// Create screenshot
const dataURL = canvas.toDataURL('image/png');
// Create download link
const downloadLink = document.createElement('a');
downloadLink.href = dataURL;
downloadLink.download = 'panorama-screenshot.png';
// Trigger download
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// Show flash effect
elements.screenshotFlash.style.opacity = '0.7';
setTimeout(() => {
elements.screenshotFlash.style.opacity = '0';
}, 100);
// Show feedback
elements.screenshotFeedback.style.opacity = '1';
setTimeout(() => {
elements.screenshotFeedback.style.opacity = '0';
}, 2000);
// Restore UI
setTimeout(() => {
elementsToHide.forEach((el, i) => el.style.display = originalDisplays[i]);
}, 300);
});
},
cleanupScene: () => {
if (state.scene) {
elements.aframeContainer.removeChild(state.scene);
state.scene = null;
}
if (state.videoElement) {
state.videoElement.pause();
state.videoElement = null;
}
elements.controls.style.display = 'none';
elements.videoControls.style.display = 'none';
elements.zoomInfo.style.display = 'none';
elements.stereoButton.style.display = 'none';
elements.resetButton.style.display = 'none';
elements.screenshotButton.style.display = 'none';
elements.dropArea.classList.remove('hidden');
elements.fileInput.value = '';
}
};
/* ------------------ Event Listeners ------------------ */
// File input and drop area
elements.selectButton.addEventListener('click', () => elements.fileInput.click());
elements.fileInput.addEventListener('change', (e) => {
if (e.target.files.length) mediaHandlers.processFile(e.target.files[0]);
});
// Drag and drop events for initial drop area
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
elements.dropArea.addEventListener(evt, helpers.preventDefaults);
});
['dragenter', 'dragover'].forEach(evt => {
elements.dropArea.addEventListener(evt, () => {
elements.dropArea.style.borderColor = '#4285f4';
elements.dropArea.style.background = '#e8f0fe';
});
});
['dragleave', 'drop'].forEach(evt => {
elements.dropArea.addEventListener(evt, () => {
elements.dropArea.style.borderColor = '#ccc';
elements.dropArea.style.background = '#f5f5f5';
});
});
elements.dropArea.addEventListener('drop', (e) => {
const f = e.dataTransfer.files[0];
if (!f) return;
const valid = /^image\/|video\/(mp4|webm)/.test(f.type);
valid ? mediaHandlers.processFile(f) : alert('Please upload an image or MP4/WebM video file.');
});
// Global drag and drop events
document.addEventListener('dragenter', (e) => {
helpers.preventDefaults(e);
// Only show if there's already media loaded AND files are being dragged
if (state.scene && !state.isDraggingFile && e.dataTransfer && e.dataTransfer.types.includes('Files')) {
state.isDraggingFile = true;
helpers.showGlobalDropOverlay(true);
}
});
document.addEventListener('dragover', (e) => {
helpers.preventDefaults(e);
// Keep the overlay visible while dragging files
if (state.isDraggingFile && e.dataTransfer && e.dataTransfer.types.includes('Files')) {
helpers.showGlobalDropOverlay(true);
}
});
document.addEventListener('dragleave', (e) => {
helpers.preventDefaults(e);
// Check if leaving the viewport
if (e.clientX <= 0 || e.clientX >= window.innerWidth ||
e.clientY <= 0 || e.clientY >= window.innerHeight) {
state.isDraggingFile = false;
helpers.showGlobalDropOverlay(false);
}
});
document.addEventListener('drop', (e) => {
helpers.preventDefaults(e);
helpers.showGlobalDropOverlay(false);
state.isDraggingFile = false;
const f = e.dataTransfer.files[0];
if (!f) return;
const valid = /^image\/|video\/(mp4|webm)/.test(f.type);
valid ? mediaHandlers.processFile(f) : alert('Please upload an image or MP4/WebM video file.');
});
// Video controls
elements.videoSlider.addEventListener('input', () => {
if (!state.videoElement || !state.videoElement.duration) return;
const newTime = (elements.videoSlider.value / 100) * state.videoElement.duration;
state.videoElement.currentTime = newTime;
if (!state.isPlaying) {
// ensure texture updates while paused
state.videoElement.pause();
}
});
elements.playPauseButton.addEventListener('click', () => {
if (!state.videoElement) return;
state.isPlaying ? state.videoElement.pause() : state.videoElement.play().catch(() => {});
state.isPlaying = !state.isPlaying;
helpers.updatePlayPauseIcon();
});
// Screenshot functionality
elements.screenshotButton.addEventListener('click', interfaceHandlers.takeScreenshot);
// Reset button
elements.resetButton.addEventListener('click', interfaceHandlers.cleanupScene);
// Stereo toggle
elements.stereoButton.addEventListener('click', () => {
if (!state.currentMedia) return;
// Cycle through stereo modes: Off -> Vertical -> Horizontal -> Off
state.stereoMode = (state.stereoMode + 1) % 3;
helpers.updateStereoButton();
// Reload the panorama with the new stereo mode
if (state.isMediaImage) {
mediaHandlers.createImagePanorama(state.currentMedia);
}
});
// Zoom handlers
document.addEventListener('wheel', (e) => {
if (!state.scene) return;
helpers.updateFov(Math.sign(e.deltaY) * 2);
e.preventDefault();
}, { passive: false });
// Pinch-to-zoom on laptop track pads and mobile devices
let pinchStartDist = 0;
document.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) pinchStartDist = helpers.dist(e.touches);
});
document.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && state.scene) {
const d = helpers.dist(e.touches);
if (Math.abs(d - pinchStartDist) > 5) {
helpers.updateFov((pinchStartDist - d) > 0 ? 1 : -1);
pinchStartDist = d;
}
e.preventDefault();
}
}, { passive: false });
// Remove default click & drag feature
document.addEventListener('dragstart', (e) => {
if (e.target.tagName === 'CANVAS' || e.target.closest('a-scene')) {
e.preventDefault();
return false;
}
});
// Check for URL parameter and load media if present
const mediaParams = getMediaUrlFromParams();
if (mediaParams.url) {
mediaHandlers.loadMediaFromUrl(mediaParams.url, mediaParams.stereoMode);
}
});
</script>
</body>
</html>