Files
drohne/viewer360.html

967 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>