1
0

4 Commits

Author SHA1 Message Date
Aiden
91b612785b Initial build 2026-06-10 10:19:03 +10:00
Verdi
3bd2c135a9 update demo page 2026-02-19 13:12:49 -06:00
Verdi
2194a4726e Force GitHub Pages rebuild 2026-02-19 13:09:41 -06:00
Verdi
858eb62947 Update CDN urls in readme 2026-02-19 12:32:47 -06:00
4 changed files with 318 additions and 213 deletions

View File

@@ -1,37 +1,45 @@
# VR180 Web Player # VR Web Player
A web-based video player for 180 degree, 3D video. A CDN-friendly web player for side-by-side stereoscopic video.
Got an immersive video you want people to see with the Apple Vision Pro or Meta Quest headsets? Now you can put it on your website just like any other video! People will see the immersive 3D video if they have a capable headset or they'll get a 2D version on other devices. The player supports two projection modes:
- `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers.
- `plane`: a flat stereoscopic video plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
## How to use it ## How to use it
1. Link to the player CSS file `<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/Verdi/VR180-Web-Player@v1.0.1/vr180player/vr180-player.css">`. Include the module script from your CDN. The script automatically loads its matching CSS file and image assets from the same folder.
2. Add the player script `<script type="module" src="https://cdn.jsdelivr.net/gh/Verdi/VR180-Web-Player@v1.0.1/vr180player/vr180-player.js"></script>` before the closing body tag.
3. And use this HTML snippet to embed your video: ```html
<div data-vr-web-player data-projection="vr180">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4">
</video>
</div>
<script type="module" src="https://cdn.example.com/vr180player/vr180-player.js"></script>
``` ```
<div id="vr-container">
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline> Use `data-projection="plane"` for flat 3D video on a rectangular plane:
```html
<div data-vr-web-player data-projection="plane">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4"> <source src="sbs-video.mp4" type="video/mp4">
</video> </video>
</div> </div>
``` ```
Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container has no video, or if `data-projection` is not `vr180` or `plane`.
## Video format
This version supports 2:1 side-by-side video using H.264 or HEVC in an mp4 file. It does not support over-under, MV-HEVC, APMP, or `.aivu`.
## How it works ## How it works
When the webpage loads, the video file is embeded normally, with a play button positioned over the poster frame. When the user clicks play, the player script checks if `navigator.xr` exists. If it does, a VR experience is initiated. If not, it builds a rectilinear, 2D view of your video and plays it that way. When the page loads, the video is embedded normally with a play button over the poster frame. When the user clicks play, the player checks for `navigator.xr` and `immersive-vr` support.
**VR on Apple Vision Pro** - In WebXR, `vr180` maps the left and right halves of the SBS video onto the matching eyes of a 180 degree sphere.
![vr](https://github.com/user-attachments/assets/c1097a4f-8712-4e6b-a233-a52d49cb261e) - In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 video plane.
- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image.
**2D in Safari on Mac**
You can drag the 2D video around to see things outside the frame.
<img width="1000" height="793" alt="2d" src="https://github.com/user-attachments/assets/094d30b7-7175-44ba-a700-d333196f8bb3" />
## Video Format
**The player only supports 2:1, side-by-side video using either H.264 or HEVC in an mp4 file.** It does not support over-under, MV-HEVC, APMP, or .aivu.
## Support
I'm not a developer and I might not be able to help you if you run into problems, want to customize this, or add new features (not that I won't try). I'm releasing this with the [unlicense](https://unlicense.org/) so you're free to do anything at all with it. That said, if you have ideas or want to contribute code, I'd love to [hear](mailto:hello@michaelverdi.com) from you.
## Demo ## Demo
**Test it out!** Open this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
Open [https://verdi.github.io/VR180-Web-Player/](https://verdi.github.io/VR180-Web-Player/) in a browser on your headset (or another device). Or check out this short film I made -> [Blandscape](https://michaelverdi.com/blandscape)

View File

@@ -3,31 +3,30 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR180 Web Player</title> <title>VR Web Player</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/Verdi/VR180-Web-Player@v1.0.1/vr180player/vr180-player.css">
<style> <style>
body { body {
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 1rem; font-size: 1rem;
font-weight: normal; font-weight: normal;
} }
main { main {
max-width: 750px; max-width: 750px;
margin: auto; margin: auto;
} }
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>VR180 Web Player</h1> <h1>VR Web Player</h1>
<p>This is a web-based player for 180° stereoscopic video.</p> <p>This is a web-based player for side-by-side stereoscopic video.</p>
<div id="vr-container"> <div data-vr-web-player data-projection="vr180">
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline> <video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4"> <source src="sbs-video.mp4" type="video/mp4">
</video> </video>
<!-- UI elements will be dynamically inserted here by JavaScript -->
</div> </div>
</main> </main>
<script type="module" src="https://cdn.jsdelivr.net/gh/Verdi/VR180-Web-Player@v1.0.1/vr180player/vr180-player.js"></script> <script type="module" src="./vr180player/vr180-player.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,16 +1,17 @@
#vr-container { .vrwp {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 100%; width: 100%;
} }
video { .vrwp-video,
.vrwp canvas {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 16/9; aspect-ratio: 16 / 9;
} }
#playBtn { .vrwp-play-button {
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@@ -25,41 +26,39 @@ video {
z-index: 10; z-index: 10;
} }
#playBtn:hover { .vrwp-play-button:hover {
transform: translate(-50%, -50%) scale(1.1); transform: translate(-50%, -50%) scale(1.1);
} }
#playBtn:active { .vrwp-play-button:active {
transform: translate(-50%, -50%) scale(0.95); transform: translate(-50%, -50%) scale(0.95);
} }
#playBtn.hidden { .vrwp-play-button.hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
#playBtn img { .vrwp-play-button img {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
/* Responsive sizing */
@media (max-width: 600px) { @media (max-width: 600px) {
#playBtn { .vrwp-play-button {
width: 60px; width: 60px;
height: 60px; height: 60px;
} }
} }
@media (min-width: 900px) { @media (min-width: 900px) {
#playBtn { .vrwp-play-button {
width: 100px; width: 100px;
height: 100px; height: 100px;
} }
} }
/* 2D Video Controls Panel */ .vrwp-panel {
#panel {
position: absolute; position: absolute;
bottom: 10%; bottom: 10%;
left: 50%; left: 50%;
@@ -69,7 +68,7 @@ video {
border-radius: 30px; border-radius: 30px;
background: rgba(0, 0, 0, 0.70); background: rgba(0, 0, 0, 0.70);
color: #fff; color: #fff;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
z-index: 100; z-index: 100;
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
@@ -77,38 +76,38 @@ video {
pointer-events: none; pointer-events: none;
} }
#panel.visible { .vrwp-panel.visible {
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
} }
#status { .vrwp-status {
margin: 0 12px 12px 12px; margin: 0 12px 12px 12px;
} }
#video-title { .vrwp-video-title {
text-align: center; text-align: center;
margin: 0 0 16px 0; margin: 0 0 16px 0;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
} }
#current-time, .vrwp-current-time,
#total-time { .vrwp-total-time {
margin: 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
#progress { .vrwp-progress {
display: grid; display: grid;
grid-template-columns: min-content 1fr min-content; grid-template-columns: min-content 1fr min-content;
grid-gap: 8px; grid-gap: 8px;
align-items: center; align-items: center;
} }
#bar { .vrwp-bar {
width: 100%; width: 100%;
height: 4px; height: 4px;
border-radius: 2px; border-radius: 2px;
@@ -117,7 +116,7 @@ video {
position: relative; position: relative;
} }
#played { .vrwp-played {
border-radius: 2px; border-radius: 2px;
background: #fff; background: #fff;
height: 4px; height: 4px;
@@ -125,20 +124,20 @@ video {
transition: width 0.1s ease; transition: width 0.1s ease;
} }
#controls { .vrwp-controls {
display: grid; display: grid;
grid-template-areas: "full lflex nav rflex mute"; grid-template-areas: "full lflex nav rflex mute";
grid-template-columns: 44px 1fr 156px 1fr 44px; grid-template-columns: 44px 1fr 156px 1fr 44px;
height: 44px; height: 44px;
} }
#panel button { .vrwp-panel button {
cursor: pointer; cursor: pointer;
border: none; border: none;
background-color: transparent; background-color: transparent;
} }
#fullscreen { .vrwp-fullscreen {
grid-area: full; grid-area: full;
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -149,11 +148,11 @@ video {
transition: background-image 0.15s ease-in-out; transition: background-image 0.15s ease-in-out;
} }
#fullscreen:hover { .vrwp-fullscreen:hover {
background-image: url(images/fullscreen-hover.png); background-image: url(images/fullscreen-hover.png);
} }
#mute { .vrwp-mute {
grid-area: mute; grid-area: mute;
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -164,27 +163,27 @@ video {
transition: background-image 0.15s ease-in-out; transition: background-image 0.15s ease-in-out;
} }
#mute:hover { .vrwp-mute:hover {
background-image: url(images/mute-hover.png); background-image: url(images/mute-hover.png);
} }
#mute.muted { .vrwp-mute.muted {
background-image: url(images/mute.png); background-image: url(images/mute.png);
} }
#mute.muted:hover { .vrwp-mute.muted:hover {
background-image: url(images/mute-hover.png); background-image: url(images/mute-hover.png);
} }
#mute.unmuted { .vrwp-mute.unmuted {
background-image: url(images/unmute.png); background-image: url(images/unmute.png);
} }
#mute.unmuted:hover { .vrwp-mute.unmuted:hover {
background-image: url(images/unmute-hover.png); background-image: url(images/unmute-hover.png);
} }
#nav { .vrwp-nav {
grid-area: nav; grid-area: nav;
display: grid; display: grid;
grid-template-columns: 44px 44px 44px; grid-template-columns: 44px 44px 44px;
@@ -192,7 +191,7 @@ video {
height: 44px; height: 44px;
} }
#back { .vrwp-back {
width: 44px; width: 44px;
height: 44px; height: 44px;
background-image: url(images/back.png); background-image: url(images/back.png);
@@ -202,11 +201,11 @@ video {
transition: background-image 0.15s ease-in-out; transition: background-image 0.15s ease-in-out;
} }
#back:hover { .vrwp-back:hover {
background-image: url(images/back-hover.png); background-image: url(images/back-hover.png);
} }
#play2 { .vrwp-play-toggle {
width: 44px; width: 44px;
height: 44px; height: 44px;
background-image: url(images/play2.png); background-image: url(images/play2.png);
@@ -216,27 +215,27 @@ video {
transition: background-image 0.15s ease-in-out; transition: background-image 0.15s ease-in-out;
} }
#play2:hover { .vrwp-play-toggle:hover {
background-image: url(images/play2-hover.png); background-image: url(images/play2-hover.png);
} }
#play2.paused { .vrwp-play-toggle.paused {
background-image: url(images/play2.png); background-image: url(images/play2.png);
} }
#play2.paused:hover { .vrwp-play-toggle.paused:hover {
background-image: url(images/play2-hover.png); background-image: url(images/play2-hover.png);
} }
#play2.playing { .vrwp-play-toggle.playing {
background-image: url(images/pause.png); background-image: url(images/pause.png);
} }
#play2.playing:hover { .vrwp-play-toggle.playing:hover {
background-image: url(images/pause-hover.png); background-image: url(images/pause-hover.png);
} }
#forward { .vrwp-forward {
width: 44px; width: 44px;
height: 44px; height: 44px;
background-image: url(images/forward.png); background-image: url(images/forward.png);
@@ -246,6 +245,6 @@ video {
transition: background-image 0.15s ease-in-out; transition: background-image 0.15s ease-in-out;
} }
#forward:hover { .vrwp-forward:hover {
background-image: url(images/forward-hover.png); background-image: url(images/forward-hover.png);
} }

View File

@@ -1,8 +1,16 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js'; import * as THREE from 'https://unpkg.com/three/build/three.module.js';
const _playerBase = new URL('.', import.meta.url).href; const _playerBase = new URL('.', import.meta.url).href;
const PLAYER_SELECTOR = '[data-vr-web-player]';
const VALID_PROJECTIONS = new Set(['vr180', 'plane']);
const PLANE_WIDTH = 3.2;
const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
const PLANE_DISTANCE = 3;
const PLANE_2D_DISTANCE = 1.2;
let playerContainer, projectionMode = 'vr180';
let scene, camera, renderer, video, videoTexture, sphereMaterial; let scene, camera, renderer, video, videoTexture, sphereMaterial;
let vr180Mesh; let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null; let xrSession = null;
let controller1, raycaster, uiElements = []; let controller1, raycaster, uiElements = [];
const tempMatrix = new THREE.Matrix4(); const tempMatrix = new THREE.Matrix4();
@@ -106,9 +114,26 @@ const SOUND_MUTED_SVG_PATH = "M6.9082 2.8985C7.71639 2.45747 8.74994 3.03437 8.7
// Dynamic UI Creation Functions // Dynamic UI Creation Functions
function injectPlayerStyles() {
if (document.querySelector('link[data-vr-web-player-stylesheet]')) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = _playerBase + 'vr180-player.css';
link.dataset.vrWebPlayerStylesheet = 'true';
if (document.head) {
document.head.appendChild(link);
} else {
document.addEventListener('DOMContentLoaded', () => document.head.appendChild(link), { once: true });
}
}
function createPlayButton() { function createPlayButton() {
const playButton = document.createElement('button'); const playButton = document.createElement('button');
playButton.id = 'playBtn'; playButton.type = 'button';
playButton.className = 'vrwp-play-button';
playButton.setAttribute('aria-label', 'Play video'); playButton.setAttribute('aria-label', 'Play video');
const playImg = document.createElement('img'); const playImg = document.createElement('img');
@@ -122,32 +147,32 @@ function createPlayButton() {
function create2DControlPanel() { function create2DControlPanel() {
const panel = document.createElement('div'); const panel = document.createElement('div');
panel.id = 'panel'; panel.className = 'vrwp-panel';
// Status section // Status section
const status = document.createElement('div'); const status = document.createElement('div');
status.id = 'status'; status.className = 'vrwp-status';
const videoTitle = document.createElement('p'); const videoTitle = document.createElement('p');
videoTitle.id = 'video-title'; videoTitle.className = 'vrwp-video-title';
videoTitle.textContent = 'Title'; videoTitle.textContent = 'Title';
const progress = document.createElement('div'); const progress = document.createElement('div');
progress.id = 'progress'; progress.className = 'vrwp-progress';
const currentTime = document.createElement('p'); const currentTime = document.createElement('p');
currentTime.id = 'current-time'; currentTime.className = 'vrwp-current-time';
currentTime.textContent = '00:00:00'; currentTime.textContent = '00:00:00';
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.id = 'bar'; bar.className = 'vrwp-bar';
const played = document.createElement('div'); const played = document.createElement('div');
played.id = 'played'; played.className = 'vrwp-played';
bar.appendChild(played); bar.appendChild(played);
const totalTime = document.createElement('p'); const totalTime = document.createElement('p');
totalTime.id = 'total-time'; totalTime.className = 'vrwp-total-time';
totalTime.textContent = '00:00:00'; totalTime.textContent = '00:00:00';
progress.appendChild(currentTime); progress.appendChild(currentTime);
@@ -159,29 +184,39 @@ function create2DControlPanel() {
// Controls section // Controls section
const controls = document.createElement('div'); const controls = document.createElement('div');
controls.id = 'controls'; controls.className = 'vrwp-controls';
const fullscreenBtn = document.createElement('button'); const fullscreenBtn = document.createElement('button');
fullscreenBtn.id = 'fullscreen'; fullscreenBtn.type = 'button';
fullscreenBtn.className = 'vrwp-fullscreen';
fullscreenBtn.setAttribute('aria-label', 'Toggle fullscreen');
const nav = document.createElement('div'); const nav = document.createElement('div');
nav.id = 'nav'; nav.className = 'vrwp-nav';
const backBtn = document.createElement('button'); const backBtn = document.createElement('button');
backBtn.id = 'back'; backBtn.type = 'button';
backBtn.className = 'vrwp-back';
backBtn.setAttribute('aria-label', 'Back 15 seconds');
const play2Btn = document.createElement('button'); const play2Btn = document.createElement('button');
play2Btn.id = 'play2'; play2Btn.type = 'button';
play2Btn.className = 'vrwp-play-toggle';
play2Btn.setAttribute('aria-label', 'Play or pause');
const forwardBtn = document.createElement('button'); const forwardBtn = document.createElement('button');
forwardBtn.id = 'forward'; forwardBtn.type = 'button';
forwardBtn.className = 'vrwp-forward';
forwardBtn.setAttribute('aria-label', 'Forward 15 seconds');
nav.appendChild(backBtn); nav.appendChild(backBtn);
nav.appendChild(play2Btn); nav.appendChild(play2Btn);
nav.appendChild(forwardBtn); nav.appendChild(forwardBtn);
const muteBtn = document.createElement('button'); const muteBtn = document.createElement('button');
muteBtn.id = 'mute'; muteBtn.type = 'button';
muteBtn.className = 'vrwp-mute';
muteBtn.setAttribute('aria-label', 'Toggle mute');
controls.appendChild(fullscreenBtn); controls.appendChild(fullscreenBtn);
controls.appendChild(nav); controls.appendChild(nav);
@@ -194,28 +229,44 @@ function create2DControlPanel() {
return panel; return panel;
} }
injectPlayerStyles();
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
videoElement = document.getElementById('vr180'); const containers = document.querySelectorAll(PLAYER_SELECTOR);
if (!videoElement) { if (containers.length === 0) {
console.error("CRITICAL_ERROR_DOM: Essential HTML element (video) not found."); console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
return; return;
} }
// Create and insert UI elements dynamically if (containers.length > 1) {
const container = document.getElementById('vr-container'); console.warn(`VR_WEB_PLAYER_DOM: This version supports exactly one ${PLAYER_SELECTOR} container per page. Found ${containers.length}; no player was initialized.`);
if (!container) {
console.error("CRITICAL_ERROR_DOM: VR container not found.");
return; return;
} }
playerContainer = containers[0];
playerContainer.classList.add('vrwp');
projectionMode = (playerContainer.dataset.projection || 'vr180').trim().toLowerCase();
if (!VALID_PROJECTIONS.has(projectionMode)) {
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${projectionMode}". Use "vr180" or "plane".`);
return;
}
videoElement = playerContainer.querySelector('video');
if (!videoElement) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`);
return;
}
videoElement.classList.add('vrwp-video');
// Create and insert play button // Create and insert play button
playBtn = createPlayButton(); playBtn = createPlayButton();
container.appendChild(playBtn); playerContainer.appendChild(playBtn);
// Create and insert 2D control panel // Create and insert 2D control panel
const controlPanel = create2DControlPanel(); const controlPanel = create2DControlPanel();
container.appendChild(controlPanel); playerContainer.appendChild(controlPanel);
playBtn.disabled = true; playBtn.disabled = true;
@@ -334,6 +385,86 @@ function createButtonTexture(textOrPathData, textColor = 'white', backgroundColo
return texture; return texture;
} }
function isLeftEyeCamera(renderingRenderer, activeCamera) {
const xrCamera = renderingRenderer.xr.getCamera();
if (xrCamera && xrCamera.cameras && xrCamera.cameras.length >= 2) {
if (activeCamera === xrCamera.cameras[0]) {
return true;
}
if (activeCamera === xrCamera.cameras[1]) {
return false;
}
const viewMatrixX = activeCamera.matrixWorldInverse.elements[12];
const leftCamX = xrCamera.cameras[0].matrixWorldInverse.elements[12];
const rightCamX = xrCamera.cameras[1].matrixWorldInverse.elements[12];
const diffToLeft = Math.abs(viewMatrixX - leftCamX);
const diffToRight = Math.abs(viewMatrixX - rightCamX);
if (diffToLeft < 0.001 || diffToLeft < diffToRight) {
return true;
}
if (diffToRight < 0.001) {
return false;
}
}
return activeCamera.projectionMatrix.elements[8] <= 0;
}
function applySbsTextureWindow(renderingRenderer, activeCamera, material) {
if (!material.map) return;
const isPresentingXR = renderingRenderer.xr.isPresenting;
// Non-XR fallback always shows the left eye so users never see the raw SBS double image.
if (is2DMode && !isPresentingXR) {
material.map.offset.x = 0;
material.map.repeat.x = 0.5;
material.map.offset.y = 0;
material.map.repeat.y = 1;
return;
}
material.map.offset.x = 0;
material.map.repeat.x = 1;
material.map.offset.y = 0;
material.map.repeat.y = 1;
if (!isPresentingXR) {
return;
}
material.map.offset.x = isLeftEyeCamera(renderingRenderer, activeCamera) ? 0 : 0.5;
material.map.repeat.x = 0.5;
}
function hideContentMeshes() {
if (vr180Mesh) vr180Mesh.visible = false;
if (planeMesh) planeMesh.visible = false;
}
function showActiveContentMesh() {
hideContentMeshes();
if (activeContentMesh) {
activeContentMesh.visible = true;
}
}
function positionPlaneForPresentation(isFallback2D = false) {
if (!planeMesh) return;
const zPosition = isFallback2D && camera2D ? camera2D.position.z - PLANE_2D_DISTANCE : -PLANE_DISTANCE;
planeMesh.position.set(0, 1.6, zPosition);
}
function createVideoTexture() {
if (videoTexture) videoTexture.dispose();
videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
videoTexture.colorSpace = THREE.SRGBColorSpace;
return videoTexture;
}
function init() { function init() {
try { try {
@@ -349,7 +480,7 @@ function init() {
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true; renderer.xr.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
document.getElementById('vr-container').appendChild(renderer.domElement); playerContainer.appendChild(renderer.domElement);
if (renderer.domElement) { if (renderer.domElement) {
renderer.domElement.style.display = 'none'; renderer.domElement.style.display = 'none';
@@ -373,11 +504,8 @@ function init() {
}, false); }, false);
gl.canvas.addEventListener('webglcontextrestored', (event) => { gl.canvas.addEventListener('webglcontextrestored', (event) => {
console.log("CONTEXT_EVENT: WebGL Context Restored."); console.log("CONTEXT_EVENT: WebGL Context Restored.");
if (video && sphereMaterial && vr180Mesh && vr180Mesh.visible && renderer.xr.isPresenting && xrSession) { if (video && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) {
if (videoTexture) videoTexture.dispose(); videoTexture = createVideoTexture();
videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter; videoTexture.magFilter = THREE.LinearFilter;
videoTexture.colorSpace = THREE.SRGBColorSpace;
sphereMaterial.map = videoTexture; sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true; sphereMaterial.needsUpdate = true;
updateVRPlayPauseButtonIcon(); updateVRPlayPauseButtonIcon();
@@ -403,73 +531,31 @@ function init() {
sphereMaterial = new THREE.MeshBasicMaterial({ map: null }); sphereMaterial = new THREE.MeshBasicMaterial({ map: null });
vr180Mesh = new THREE.Mesh(sphereGeometry, sphereMaterial); vr180Mesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
vr180Mesh.name = "vr180Mesh"; vr180Mesh.name = "vr180Mesh";
uiElements.push(vr180Mesh);
vr180Mesh.rotation.y = Math.PI / 2; vr180Mesh.rotation.y = Math.PI / 2;
scene.add(vr180Mesh); scene.add(vr180Mesh);
vr180Mesh.visible = false; vr180Mesh.visible = false;
vr180Mesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) { vr180Mesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) {
if (!material.map) return; applySbsTextureWindow(renderer, activeCamera, material);
const isPresentingXR = renderer.xr.isPresenting;
// Handle 2D mode - show only left eye view
if (is2DMode && !isPresentingXR) {
material.map.offset.x = 0;
material.map.repeat.x = 0.5;
material.map.offset.y = 0;
material.map.repeat.y = 1;
return;
}
// Default to full texture for non-VR, non-2D mode
material.map.offset.x = 0; material.map.repeat.x = 1;
material.map.offset.y = 0; material.map.repeat.y = 1;
if (!isPresentingXR) {
return;
}
const xrCamera = renderer.xr.getCamera();
let isLeftEye = true; // Default to left eye
if (xrCamera && xrCamera.cameras && xrCamera.cameras.length >= 2) {
// Method 1: Direct camera reference comparison
if (activeCamera === xrCamera.cameras[0]) {
isLeftEye = true;
} else if (activeCamera === xrCamera.cameras[1]) {
isLeftEye = false;
} else {
// Method 2: View matrix position (matrixWorldInverse.elements[12] is the X translation)
const viewMatrixX = activeCamera.matrixWorldInverse.elements[12];
const leftCamX = xrCamera.cameras[0].matrixWorldInverse.elements[12];
const rightCamX = xrCamera.cameras[1].matrixWorldInverse.elements[12];
const diffToLeft = Math.abs(viewMatrixX - leftCamX);
const diffToRight = Math.abs(viewMatrixX - rightCamX);
if (diffToLeft < 0.001 || diffToLeft < diffToRight) {
isLeftEye = true;
} else if (diffToRight < 0.001) {
isLeftEye = false;
} else {
// Method 3: Projection matrix asymmetry (elements[8] indicates eye offset)
const projMatrixEl8 = activeCamera.projectionMatrix.elements[8];
isLeftEye = projMatrixEl8 <= 0;
}
}
} else {
// Fallback when xrCamera.cameras is not available
const projMatrixEl8 = activeCamera.projectionMatrix.elements[8];
isLeftEye = projMatrixEl8 <= 0;
}
material.map.offset.x = isLeftEye ? 0 : 0.5;
material.map.repeat.x = 0.5;
}; };
const planeGeometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT);
planeMesh = new THREE.Mesh(planeGeometry, sphereMaterial);
planeMesh.name = "vrSbsPlaneMesh";
planeMesh.position.set(0, 1.6, -PLANE_DISTANCE);
planeMesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) {
applySbsTextureWindow(renderer, activeCamera, material);
};
scene.add(planeMesh);
planeMesh.visible = false;
activeContentMesh = projectionMode === 'plane' ? planeMesh : vr180Mesh;
uiElements.push(activeContentMesh);
// Initialize 2D camera // Initialize 2D camera
camera2D = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera2D = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera2D.position.set(0, 0, 0); camera2D.position.set(0, 1.6, 0.1);
camera2D.rotation.set(0, 0, 0); camera2D.rotation.set(0, 0, 0);
} catch (e) { } catch (e) {
console.error("INIT_ERROR (Phase 1 - Core Setup):", e); console.error("INIT_ERROR (Phase 1 - Core Setup):", e);
@@ -851,7 +937,7 @@ function onWindowResize() {
if (is2DMode) { if (is2DMode) {
// In 2D mode, calculate canvas size based on container dimensions // In 2D mode, calculate canvas size based on container dimensions
const container = document.getElementById('vr-container'); const container = playerContainer;
if (container) { if (container) {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const containerWidth = containerRect.width; const containerWidth = containerRect.width;
@@ -1002,7 +1088,11 @@ function onTouchEnd(event) {
function render2D() { function render2D() {
if (!is2DMode) return; if (!is2DMode) return;
updateCameraRotation(); if (projectionMode === 'vr180') {
updateCameraRotation();
} else if (camera2D) {
camera2D.rotation.set(0, 0, 0);
}
if (renderer && camera2D && scene) { if (renderer && camera2D && scene) {
renderer.render(scene, camera2D); renderer.render(scene, camera2D);
@@ -1014,17 +1104,17 @@ function render2D() {
// 2D Control Panel Functions // 2D Control Panel Functions
function init2DControlPanel() { function init2DControlPanel() {
// Get references to 2D control elements // Get references to 2D control elements
controlPanel = document.getElementById('panel'); controlPanel = playerContainer.querySelector('.vrwp-panel');
videoTitle = document.getElementById('video-title'); videoTitle = playerContainer.querySelector('.vrwp-video-title');
currentTimeDisplay = document.getElementById('current-time'); currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
totalTimeDisplay = document.getElementById('total-time'); totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
progressBar = document.getElementById('bar'); progressBar = playerContainer.querySelector('.vrwp-bar');
playedBar = document.getElementById('played'); playedBar = playerContainer.querySelector('.vrwp-played');
fullscreenBtn = document.getElementById('fullscreen'); fullscreenBtn = playerContainer.querySelector('.vrwp-fullscreen');
backBtn = document.getElementById('back'); backBtn = playerContainer.querySelector('.vrwp-back');
play2Btn = document.getElementById('play2'); play2Btn = playerContainer.querySelector('.vrwp-play-toggle');
forwardBtn = document.getElementById('forward'); forwardBtn = playerContainer.querySelector('.vrwp-forward');
muteBtn = document.getElementById('mute'); muteBtn = playerContainer.querySelector('.vrwp-mute');
if (!controlPanel) { if (!controlPanel) {
console.error("2D Control panel not found"); console.error("2D Control panel not found");
@@ -1089,9 +1179,6 @@ function init2DControlPanel() {
show2DControlPanel(); show2DControlPanel();
}); });
} }
// Mouse movement listener will be added to canvas in start2DMode
document.addEventListener('touchstart', on2DTouchStart);
} }
function show2DControlPanel() { function show2DControlPanel() {
@@ -1118,6 +1205,12 @@ function onCanvasMouseMove() {
} }
} }
function onCanvasTouchStart() {
if (is2DMode) {
show2DControlPanel();
}
}
function on2DMouseMove() { function on2DMouseMove() {
if (is2DMode) { if (is2DMode) {
show2DControlPanel(); show2DControlPanel();
@@ -1178,7 +1271,7 @@ function update2DMuteButton() {
function toggle2DFullscreen() { function toggle2DFullscreen() {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
// Enter fullscreen // Enter fullscreen
const container = document.getElementById('vr-container'); const container = playerContainer;
if (container && container.requestFullscreen) { if (container && container.requestFullscreen) {
container.requestFullscreen().catch(err => { container.requestFullscreen().catch(err => {
console.error('Error attempting to enable fullscreen:', err); console.error('Error attempting to enable fullscreen:', err);
@@ -1218,7 +1311,7 @@ function position2DControlPanel() {
// Get the canvas dimensions and position // Get the canvas dimensions and position
const canvas = renderer.domElement; const canvas = renderer.domElement;
const canvasRect = canvas.getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
const containerRect = document.getElementById('vr-container').getBoundingClientRect(); const containerRect = playerContainer.getBoundingClientRect();
// Calculate 10% from the bottom of the canvas // Calculate 10% from the bottom of the canvas
const bottomOffset = canvasRect.height * 0.1; const bottomOffset = canvasRect.height * 0.1;
@@ -1319,6 +1412,7 @@ function resetToOriginalState() {
cameraRotation = { yaw: 0, pitch: 0 }; cameraRotation = { yaw: 0, pitch: 0 };
cameraVelocity = { yaw: 0, pitch: 0 }; cameraVelocity = { yaw: 0, pitch: 0 };
isDragging = false; isDragging = false;
positionPlaneForPresentation(false);
// Hide WebGL canvas and show video element // Hide WebGL canvas and show video element
if (renderer && renderer.domElement) { if (renderer && renderer.domElement) {
@@ -1392,7 +1486,7 @@ function onSelectStartVR(event) {
const newTime = Math.max(0, Math.min(1, normalizedPosition)) * video.duration; const newTime = Math.max(0, Math.min(1, normalizedPosition)) * video.duration;
video.currentTime = newTime; video.currentTime = newTime;
updateSeekBarAppearance(); updateSeekBarAppearance();
} else if (firstIntersected.name === "vrControlPanelBackground" || firstIntersected.name === "vr180Mesh") { } else if (firstIntersected.name === "vrControlPanelBackground" || firstIntersected === activeContentMesh) {
if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel(); if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel();
} else { } else {
if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel(); if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel();
@@ -1431,7 +1525,7 @@ function start2DMode() {
is2DMode = true; is2DMode = true;
// Calculate canvas size based on container dimensions (same logic as onWindowResize) // Calculate canvas size based on container dimensions (same logic as onWindowResize)
const container = document.getElementById('vr-container'); const container = playerContainer;
if (container) { if (container) {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const containerWidth = containerRect.width; const containerWidth = containerRect.width;
@@ -1464,17 +1558,14 @@ function start2DMode() {
canvas.style.display = ''; canvas.style.display = '';
// Create video texture if not exists // Create video texture if not exists
if (videoTexture) videoTexture.dispose(); videoTexture = createVideoTexture();
videoTexture = new THREE.VideoTexture(video); positionPlaneForPresentation(projectionMode === 'plane');
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
videoTexture.colorSpace = THREE.SRGBColorSpace;
// Apply texture to sphere material and make mesh visible // Apply texture to the selected projection mesh and make it visible
if (sphereMaterial && vr180Mesh) { if (sphereMaterial && activeContentMesh) {
sphereMaterial.map = videoTexture; sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true; sphereMaterial.needsUpdate = true;
vr180Mesh.visible = true; showActiveContentMesh();
} }
// Start video playback // Start video playback
@@ -1500,21 +1591,29 @@ function start2DMode() {
} }
function add2DEventListeners() { function add2DEventListeners() {
// Mouse events
renderer.domElement.addEventListener('mousedown', onMouseDown);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('mouseup', onMouseUp);
// Canvas-specific mouse movement for showing controls // Canvas-specific mouse movement for showing controls
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove); renderer.domElement.addEventListener('mousemove', onCanvasMouseMove);
// Touch events if (projectionMode === 'vr180') {
renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false }); // Mouse events
renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false }); renderer.domElement.addEventListener('mousedown', onMouseDown);
renderer.domElement.addEventListener('touchend', onTouchEnd, { passive: false }); renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('mouseup', onMouseUp);
// Touch events
renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
renderer.domElement.addEventListener('touchend', onTouchEnd, { passive: false });
} else {
renderer.domElement.addEventListener('touchstart', onCanvasTouchStart, { passive: true });
}
} }
function remove2DEventListeners() { function remove2DEventListeners() {
if (!renderer || !renderer.domElement) return;
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove);
// Mouse events // Mouse events
renderer.domElement.removeEventListener('mousedown', onMouseDown); renderer.domElement.removeEventListener('mousedown', onMouseDown);
renderer.domElement.removeEventListener('mousemove', onMouseMove); renderer.domElement.removeEventListener('mousemove', onMouseMove);
@@ -1524,6 +1623,7 @@ function remove2DEventListeners() {
renderer.domElement.removeEventListener('touchstart', onTouchStart); renderer.domElement.removeEventListener('touchstart', onTouchStart);
renderer.domElement.removeEventListener('touchmove', onTouchMove); renderer.domElement.removeEventListener('touchmove', onTouchMove);
renderer.domElement.removeEventListener('touchend', onTouchEnd); renderer.domElement.removeEventListener('touchend', onTouchEnd);
renderer.domElement.removeEventListener('touchstart', onCanvasTouchStart);
// Fullscreen events // Fullscreen events
document.removeEventListener('fullscreenchange', onFullscreenChange); document.removeEventListener('fullscreenchange', onFullscreenChange);
@@ -1592,16 +1692,15 @@ async function actualSessionToggle() {
} }
if (camera) camera.updateProjectionMatrix(); if (camera) camera.updateProjectionMatrix();
positionPlaneForPresentation(false);
if (videoTexture) { videoTexture.dispose(); videoTexture = null; } if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
if (video) { if (video) {
videoTexture = new THREE.VideoTexture(video); videoTexture = createVideoTexture();
videoTexture.minFilter = THREE.LinearFilter; videoTexture.magFilter = THREE.LinearFilter; if (activeContentMesh && sphereMaterial) {
videoTexture.colorSpace = THREE.SRGBColorSpace;
if (vr180Mesh && sphereMaterial) {
sphereMaterial.map = videoTexture; sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true; sphereMaterial.needsUpdate = true;
vr180Mesh.visible = true; showActiveContentMesh();
} else { throw new Error("VR mesh components not ready for texture."); } } else { throw new Error("VR mesh components not ready for texture."); }
} else { } else {
throw new Error("Video element not available for creating texture."); throw new Error("Video element not available for creating texture.");
@@ -1631,7 +1730,7 @@ async function actualSessionToggle() {
console.error(sessionStartError, err); console.error(sessionStartError, err);
isXrLoopActive = false; isXrLoopActive = false;
if (vr180Mesh) vr180Mesh.visible = false; hideContentMeshes();
if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; } if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
if (videoTexture) { videoTexture.dispose(); videoTexture = null; } if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
if (vrControlPanel) { if (vrControlPanel) {
@@ -1682,7 +1781,7 @@ function onVRSessionEnd(event) {
if (videoTexture) { if (videoTexture) {
videoTexture.dispose(); videoTexture = null; videoTexture.dispose(); videoTexture = null;
} }
if (vr180Mesh) vr180Mesh.visible = false; hideContentMeshes();
if (vrControlPanel) { if (vrControlPanel) {
clearTimeout(panelHideTimeout); clearTimeout(panelHideTimeout);
isPanelFading = false; isPanelFading = false;