forked from EXT/VR180-Web-Player
Compare commits
26 Commits
2D
...
91b612785b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b612785b | ||
|
|
3bd2c135a9 | ||
|
|
2194a4726e | ||
|
|
858eb62947 | ||
|
|
d52a722ce7 | ||
|
|
40e3711925 | ||
|
|
c41ad12b32 | ||
|
|
dbe6e5b1d9 | ||
|
|
626b7f451b | ||
|
|
a56e36eaf6 | ||
|
|
bebcb3d355 | ||
|
|
f4fb9cf6bb | ||
|
|
38200c82f2 | ||
|
|
715e762fc9 | ||
|
|
928fafa290 | ||
|
|
065e8310e3 | ||
|
|
4183ae2530 | ||
|
|
d869f75e1e | ||
|
|
b559ea6ebf | ||
|
|
85baf3cd79 | ||
|
|
0a8cb8196c | ||
|
|
5087c3cbb2 | ||
|
|
0ef4ca56a5 | ||
|
|
ffb29bc4ec | ||
|
|
090ad5f315 | ||
|
|
957f1af8b0 |
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
index.html export-ignore
|
||||
README.md export-ignore
|
||||
sbs-video.mp4 export-ignore
|
||||
poster.jpg export-ignore
|
||||
58
README.md
58
README.md
@@ -1,43 +1,45 @@
|
||||
# VR180 Web Player
|
||||
A web-based video player for 180 degree, 3D video.
|
||||
# VR Web Player
|
||||
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? You could build an app and deal with app stores. You could jump through some hoops to put it on YouTube but it will be limited to Meta headsets. Or you can use this web player and put it on your website.
|
||||
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
|
||||
Add the player script `<script type="module" src="vr180-player.js"></script>` before the closing body tag and use this HTML snippet:
|
||||
```
|
||||
<div id="vr-container">
|
||||
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline>
|
||||
Include the module script from your CDN. The script automatically loads its matching CSS file and image assets from the same folder.
|
||||
|
||||
```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>
|
||||
<button id="playBtn">Play</button>
|
||||
|
||||
<script type="module" src="https://cdn.example.com/vr180player/vr180-player.js"></script>
|
||||
```
|
||||
This creates a button on your page. When VR is available, the button will be active and clicking it will begin the immersive experience.
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/05db6208-6d42-48fa-a0da-55de41f35e6d" width=50%>
|
||||
Use `data-projection="plane"` for flat 3D video on a rectangular plane:
|
||||
|
||||
*Example Button*
|
||||
```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">
|
||||
</video>
|
||||
</div>
|
||||
```
|
||||
|
||||
Once the video is playing, you can bring up video controls. When the video is over, you'll automatically exit the experience.
|
||||
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`.
|
||||
|
||||
## 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, or .aivu.
|
||||
## How it works
|
||||
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.
|
||||
|
||||
## Features
|
||||
Tapping anywhere will bring up the controls. Without interaction they will go away in 10 seconds. Tapping outside of the controls will close them right away.
|
||||
- Play/Pause
|
||||
- Rewind 15 seconds
|
||||
- Skip 15 seconds
|
||||
- Mute/Unmute
|
||||
- Seek
|
||||
- Exit VR
|
||||
|
||||
## Future
|
||||
I'm not a developer. I used AI to help create this and give me the ability to post immersive videos on my website. I'm unlikely to be able to help you if you run into problems, want to customize this, or add new features. 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.
|
||||
- In WebXR, `vr180` maps the left and right halves of the SBS video onto the matching eyes of a 180 degree sphere.
|
||||
- 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.
|
||||
|
||||
## Demo
|
||||
**Test it out in a headset!**
|
||||
Open [https://verdi.github.io/VR180-Web-Player/](https://verdi.github.io/VR180-Web-Player/) in a browser on your headset and then click the Enter VR button.
|
||||
Open this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
|
||||
|
||||
22
index.html
22
index.html
@@ -3,36 +3,30 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>VR180 Web Player</title>
|
||||
<link rel="stylesheet" href="vr180player/vr180-player.css">
|
||||
<title>VR Web Player</title>
|
||||
<style>
|
||||
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-weight: normal;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 750px;
|
||||
margin: auto;
|
||||
}
|
||||
video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>VR180 Web Player</h1>
|
||||
<p>This is a web-based player for 180° stereoscopic video.</p>
|
||||
<div id="vr-container">
|
||||
<video id="vr180" poster="poster.jpg" title="Demo Video" crossOrigin="anonymous" playsinline>
|
||||
<h1>VR Web Player</h1>
|
||||
<p>This is a web-based player for side-by-side stereoscopic video.</p>
|
||||
<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>
|
||||
<!-- UI elements will be dynamically inserted here by JavaScript -->
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="vr180player/vr180-player.js"></script>
|
||||
<script type="module" src="./vr180player/vr180-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
sbs-video.mp4
BIN
sbs-video.mp4
Binary file not shown.
@@ -1,10 +1,17 @@
|
||||
#vr-container {
|
||||
.vrwp {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#playBtn {
|
||||
.vrwp-video,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.vrwp-play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -19,41 +26,39 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#playBtn:hover {
|
||||
.vrwp-play-button:hover {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
#playBtn:active {
|
||||
.vrwp-play-button:active {
|
||||
transform: translate(-50%, -50%) scale(0.95);
|
||||
}
|
||||
|
||||
#playBtn.hidden {
|
||||
.vrwp-play-button.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#playBtn img {
|
||||
.vrwp-play-button img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Responsive sizing */
|
||||
@media (max-width: 600px) {
|
||||
#playBtn {
|
||||
.vrwp-play-button {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
#playBtn {
|
||||
.vrwp-play-button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 2D Video Controls Panel */
|
||||
#panel {
|
||||
.vrwp-panel {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 50%;
|
||||
@@ -63,7 +68,7 @@
|
||||
border-radius: 30px;
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
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;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -71,38 +76,38 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#panel.visible {
|
||||
.vrwp-panel.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#status {
|
||||
.vrwp-status {
|
||||
margin: 0 12px 12px 12px;
|
||||
}
|
||||
|
||||
#video-title {
|
||||
.vrwp-video-title {
|
||||
text-align: center;
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#current-time,
|
||||
#total-time {
|
||||
.vrwp-current-time,
|
||||
.vrwp-total-time {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
#progress {
|
||||
.vrwp-progress {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
grid-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#bar {
|
||||
.vrwp-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
@@ -111,7 +116,7 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#played {
|
||||
.vrwp-played {
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
height: 4px;
|
||||
@@ -119,20 +124,20 @@
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
#controls {
|
||||
.vrwp-controls {
|
||||
display: grid;
|
||||
grid-template-areas: "full lflex nav rflex mute";
|
||||
grid-template-columns: 44px 1fr 156px 1fr 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
#panel button {
|
||||
.vrwp-panel button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#fullscreen {
|
||||
.vrwp-fullscreen {
|
||||
grid-area: full;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -143,11 +148,11 @@
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#fullscreen:hover {
|
||||
.vrwp-fullscreen:hover {
|
||||
background-image: url(images/fullscreen-hover.png);
|
||||
}
|
||||
|
||||
#mute {
|
||||
.vrwp-mute {
|
||||
grid-area: mute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -158,27 +163,27 @@
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#mute:hover {
|
||||
.vrwp-mute:hover {
|
||||
background-image: url(images/mute-hover.png);
|
||||
}
|
||||
|
||||
#mute.muted {
|
||||
.vrwp-mute.muted {
|
||||
background-image: url(images/mute.png);
|
||||
}
|
||||
|
||||
#mute.muted:hover {
|
||||
.vrwp-mute.muted:hover {
|
||||
background-image: url(images/mute-hover.png);
|
||||
}
|
||||
|
||||
#mute.unmuted {
|
||||
.vrwp-mute.unmuted {
|
||||
background-image: url(images/unmute.png);
|
||||
}
|
||||
|
||||
#mute.unmuted:hover {
|
||||
.vrwp-mute.unmuted:hover {
|
||||
background-image: url(images/unmute-hover.png);
|
||||
}
|
||||
|
||||
#nav {
|
||||
.vrwp-nav {
|
||||
grid-area: nav;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 44px 44px;
|
||||
@@ -186,7 +191,7 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
#back {
|
||||
.vrwp-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-image: url(images/back.png);
|
||||
@@ -196,11 +201,11 @@
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#back:hover {
|
||||
.vrwp-back:hover {
|
||||
background-image: url(images/back-hover.png);
|
||||
}
|
||||
|
||||
#play2 {
|
||||
.vrwp-play-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-image: url(images/play2.png);
|
||||
@@ -210,27 +215,27 @@
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#play2:hover {
|
||||
.vrwp-play-toggle:hover {
|
||||
background-image: url(images/play2-hover.png);
|
||||
}
|
||||
|
||||
#play2.paused {
|
||||
.vrwp-play-toggle.paused {
|
||||
background-image: url(images/play2.png);
|
||||
}
|
||||
|
||||
#play2.paused:hover {
|
||||
.vrwp-play-toggle.paused:hover {
|
||||
background-image: url(images/play2-hover.png);
|
||||
}
|
||||
|
||||
#play2.playing {
|
||||
.vrwp-play-toggle.playing {
|
||||
background-image: url(images/pause.png);
|
||||
}
|
||||
|
||||
#play2.playing:hover {
|
||||
.vrwp-play-toggle.playing:hover {
|
||||
background-image: url(images/pause-hover.png);
|
||||
}
|
||||
|
||||
#forward {
|
||||
.vrwp-forward {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-image: url(images/forward.png);
|
||||
@@ -240,6 +245,6 @@
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#forward:hover {
|
||||
.vrwp-forward:hover {
|
||||
background-image: url(images/forward-hover.png);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.176.0/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 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 vr180Mesh;
|
||||
let vr180Mesh, planeMesh, activeContentMesh;
|
||||
let xrSession = null;
|
||||
let controller1, raycaster, uiElements = [];
|
||||
const tempMatrix = new THREE.Matrix4();
|
||||
@@ -105,13 +114,30 @@ const SOUND_MUTED_SVG_PATH = "M6.9082 2.8985C7.71639 2.45747 8.74994 3.03437 8.7
|
||||
|
||||
|
||||
// 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() {
|
||||
const playButton = document.createElement('button');
|
||||
playButton.id = 'playBtn';
|
||||
playButton.type = 'button';
|
||||
playButton.className = 'vrwp-play-button';
|
||||
playButton.setAttribute('aria-label', 'Play video');
|
||||
|
||||
const playImg = document.createElement('img');
|
||||
playImg.src = 'vr180player/images/play.png';
|
||||
playImg.src = _playerBase + 'images/play.png';
|
||||
playImg.alt = 'Play';
|
||||
|
||||
playButton.appendChild(playImg);
|
||||
@@ -121,32 +147,32 @@ function createPlayButton() {
|
||||
|
||||
function create2DControlPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'panel';
|
||||
panel.className = 'vrwp-panel';
|
||||
|
||||
// Status section
|
||||
const status = document.createElement('div');
|
||||
status.id = 'status';
|
||||
status.className = 'vrwp-status';
|
||||
|
||||
const videoTitle = document.createElement('p');
|
||||
videoTitle.id = 'video-title';
|
||||
videoTitle.className = 'vrwp-video-title';
|
||||
videoTitle.textContent = 'Title';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.id = 'progress';
|
||||
progress.className = 'vrwp-progress';
|
||||
|
||||
const currentTime = document.createElement('p');
|
||||
currentTime.id = 'current-time';
|
||||
currentTime.className = 'vrwp-current-time';
|
||||
currentTime.textContent = '00:00:00';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'bar';
|
||||
bar.className = 'vrwp-bar';
|
||||
|
||||
const played = document.createElement('div');
|
||||
played.id = 'played';
|
||||
played.className = 'vrwp-played';
|
||||
bar.appendChild(played);
|
||||
|
||||
const totalTime = document.createElement('p');
|
||||
totalTime.id = 'total-time';
|
||||
totalTime.className = 'vrwp-total-time';
|
||||
totalTime.textContent = '00:00:00';
|
||||
|
||||
progress.appendChild(currentTime);
|
||||
@@ -158,29 +184,39 @@ function create2DControlPanel() {
|
||||
|
||||
// Controls section
|
||||
const controls = document.createElement('div');
|
||||
controls.id = 'controls';
|
||||
controls.className = 'vrwp-controls';
|
||||
|
||||
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');
|
||||
nav.id = 'nav';
|
||||
nav.className = 'vrwp-nav';
|
||||
|
||||
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');
|
||||
play2Btn.id = 'play2';
|
||||
play2Btn.type = 'button';
|
||||
play2Btn.className = 'vrwp-play-toggle';
|
||||
play2Btn.setAttribute('aria-label', 'Play or pause');
|
||||
|
||||
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(play2Btn);
|
||||
nav.appendChild(forwardBtn);
|
||||
|
||||
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(nav);
|
||||
@@ -193,28 +229,44 @@ function create2DControlPanel() {
|
||||
return panel;
|
||||
}
|
||||
|
||||
injectPlayerStyles();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
videoElement = document.getElementById('vr180');
|
||||
const containers = document.querySelectorAll(PLAYER_SELECTOR);
|
||||
|
||||
if (containers.length === 0) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (containers.length > 1) {
|
||||
console.warn(`VR_WEB_PLAYER_DOM: This version supports exactly one ${PLAYER_SELECTOR} container per page. Found ${containers.length}; no player was initialized.`);
|
||||
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("CRITICAL_ERROR_DOM: Essential HTML element (video) not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and insert UI elements dynamically
|
||||
const container = document.getElementById('vr-container');
|
||||
if (!container) {
|
||||
console.error("CRITICAL_ERROR_DOM: VR container not found.");
|
||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`);
|
||||
return;
|
||||
}
|
||||
videoElement.classList.add('vrwp-video');
|
||||
|
||||
// Create and insert play button
|
||||
playBtn = createPlayButton();
|
||||
container.appendChild(playBtn);
|
||||
playerContainer.appendChild(playBtn);
|
||||
|
||||
// Create and insert 2D control panel
|
||||
const controlPanel = create2DControlPanel();
|
||||
container.appendChild(controlPanel);
|
||||
playerContainer.appendChild(controlPanel);
|
||||
|
||||
playBtn.disabled = true;
|
||||
|
||||
@@ -333,6 +385,86 @@ function createButtonTexture(textOrPathData, textColor = 'white', backgroundColo
|
||||
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() {
|
||||
try {
|
||||
@@ -348,7 +480,7 @@ function init() {
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.xr.enabled = true;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
document.getElementById('vr-container').appendChild(renderer.domElement);
|
||||
playerContainer.appendChild(renderer.domElement);
|
||||
|
||||
if (renderer.domElement) {
|
||||
renderer.domElement.style.display = 'none';
|
||||
@@ -372,11 +504,8 @@ function init() {
|
||||
}, false);
|
||||
gl.canvas.addEventListener('webglcontextrestored', (event) => {
|
||||
console.log("CONTEXT_EVENT: WebGL Context Restored.");
|
||||
if (video && sphereMaterial && vr180Mesh && vr180Mesh.visible && renderer.xr.isPresenting && xrSession) {
|
||||
if (videoTexture) videoTexture.dispose();
|
||||
videoTexture = new THREE.VideoTexture(video);
|
||||
videoTexture.minFilter = THREE.LinearFilter; videoTexture.magFilter = THREE.LinearFilter;
|
||||
videoTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
if (video && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) {
|
||||
videoTexture = createVideoTexture();
|
||||
sphereMaterial.map = videoTexture;
|
||||
sphereMaterial.needsUpdate = true;
|
||||
updateVRPlayPauseButtonIcon();
|
||||
@@ -402,56 +531,31 @@ function init() {
|
||||
sphereMaterial = new THREE.MeshBasicMaterial({ map: null });
|
||||
vr180Mesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
|
||||
vr180Mesh.name = "vr180Mesh";
|
||||
uiElements.push(vr180Mesh);
|
||||
|
||||
vr180Mesh.rotation.y = Math.PI / 2;
|
||||
scene.add(vr180Mesh);
|
||||
vr180Mesh.visible = false;
|
||||
|
||||
vr180Mesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) {
|
||||
if (!material.map) return;
|
||||
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();
|
||||
|
||||
if (xrCamera && xrCamera.cameras && xrCamera.cameras.length >= 2) {
|
||||
if (activeCamera === xrCamera.cameras[0]) {
|
||||
material.map.offset.x = 0;
|
||||
} else if (activeCamera === xrCamera.cameras[1]) {
|
||||
material.map.offset.x = 0.5;
|
||||
} else {
|
||||
material.map.offset.x = 0;
|
||||
}
|
||||
material.map.repeat.x = 0.5;
|
||||
} else {
|
||||
const projMatrixEl8 = activeCamera.projectionMatrix.elements[8];
|
||||
if (projMatrixEl8 < -0.0001) {
|
||||
material.map.offset.x = 0; material.map.repeat.x = 0.5;
|
||||
} else if (projMatrixEl8 > 0.0001) {
|
||||
material.map.offset.x = 0.5; material.map.repeat.x = 0.5;
|
||||
}
|
||||
}
|
||||
applySbsTextureWindow(renderer, activeCamera, material);
|
||||
};
|
||||
|
||||
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
|
||||
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);
|
||||
} catch (e) {
|
||||
console.error("INIT_ERROR (Phase 1 - Core Setup):", e);
|
||||
@@ -833,7 +937,7 @@ function onWindowResize() {
|
||||
|
||||
if (is2DMode) {
|
||||
// In 2D mode, calculate canvas size based on container dimensions
|
||||
const container = document.getElementById('vr-container');
|
||||
const container = playerContainer;
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
@@ -984,7 +1088,11 @@ function onTouchEnd(event) {
|
||||
function render2D() {
|
||||
if (!is2DMode) return;
|
||||
|
||||
if (projectionMode === 'vr180') {
|
||||
updateCameraRotation();
|
||||
} else if (camera2D) {
|
||||
camera2D.rotation.set(0, 0, 0);
|
||||
}
|
||||
|
||||
if (renderer && camera2D && scene) {
|
||||
renderer.render(scene, camera2D);
|
||||
@@ -996,17 +1104,17 @@ function render2D() {
|
||||
// 2D Control Panel Functions
|
||||
function init2DControlPanel() {
|
||||
// Get references to 2D control elements
|
||||
controlPanel = document.getElementById('panel');
|
||||
videoTitle = document.getElementById('video-title');
|
||||
currentTimeDisplay = document.getElementById('current-time');
|
||||
totalTimeDisplay = document.getElementById('total-time');
|
||||
progressBar = document.getElementById('bar');
|
||||
playedBar = document.getElementById('played');
|
||||
fullscreenBtn = document.getElementById('fullscreen');
|
||||
backBtn = document.getElementById('back');
|
||||
play2Btn = document.getElementById('play2');
|
||||
forwardBtn = document.getElementById('forward');
|
||||
muteBtn = document.getElementById('mute');
|
||||
controlPanel = playerContainer.querySelector('.vrwp-panel');
|
||||
videoTitle = playerContainer.querySelector('.vrwp-video-title');
|
||||
currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
|
||||
totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
|
||||
progressBar = playerContainer.querySelector('.vrwp-bar');
|
||||
playedBar = playerContainer.querySelector('.vrwp-played');
|
||||
fullscreenBtn = playerContainer.querySelector('.vrwp-fullscreen');
|
||||
backBtn = playerContainer.querySelector('.vrwp-back');
|
||||
play2Btn = playerContainer.querySelector('.vrwp-play-toggle');
|
||||
forwardBtn = playerContainer.querySelector('.vrwp-forward');
|
||||
muteBtn = playerContainer.querySelector('.vrwp-mute');
|
||||
|
||||
if (!controlPanel) {
|
||||
console.error("2D Control panel not found");
|
||||
@@ -1071,9 +1179,6 @@ function init2DControlPanel() {
|
||||
show2DControlPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse movement listener will be added to canvas in start2DMode
|
||||
document.addEventListener('touchstart', on2DTouchStart);
|
||||
}
|
||||
|
||||
function show2DControlPanel() {
|
||||
@@ -1100,6 +1205,12 @@ function onCanvasMouseMove() {
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasTouchStart() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function on2DMouseMove() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
@@ -1160,7 +1271,7 @@ function update2DMuteButton() {
|
||||
function toggle2DFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
// Enter fullscreen
|
||||
const container = document.getElementById('vr-container');
|
||||
const container = playerContainer;
|
||||
if (container && container.requestFullscreen) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('Error attempting to enable fullscreen:', err);
|
||||
@@ -1200,7 +1311,7 @@ function position2DControlPanel() {
|
||||
// Get the canvas dimensions and position
|
||||
const canvas = renderer.domElement;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const containerRect = document.getElementById('vr-container').getBoundingClientRect();
|
||||
const containerRect = playerContainer.getBoundingClientRect();
|
||||
|
||||
// Calculate 10% from the bottom of the canvas
|
||||
const bottomOffset = canvasRect.height * 0.1;
|
||||
@@ -1301,6 +1412,7 @@ function resetToOriginalState() {
|
||||
cameraRotation = { yaw: 0, pitch: 0 };
|
||||
cameraVelocity = { yaw: 0, pitch: 0 };
|
||||
isDragging = false;
|
||||
positionPlaneForPresentation(false);
|
||||
|
||||
// Hide WebGL canvas and show video element
|
||||
if (renderer && renderer.domElement) {
|
||||
@@ -1374,7 +1486,7 @@ function onSelectStartVR(event) {
|
||||
const newTime = Math.max(0, Math.min(1, normalizedPosition)) * video.duration;
|
||||
video.currentTime = newTime;
|
||||
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();
|
||||
} else {
|
||||
if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel();
|
||||
@@ -1413,7 +1525,7 @@ function start2DMode() {
|
||||
is2DMode = true;
|
||||
|
||||
// Calculate canvas size based on container dimensions (same logic as onWindowResize)
|
||||
const container = document.getElementById('vr-container');
|
||||
const container = playerContainer;
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const containerWidth = containerRect.width;
|
||||
@@ -1436,9 +1548,7 @@ function start2DMode() {
|
||||
|
||||
// Position the canvas to match the video element
|
||||
const canvas = renderer.domElement;
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.position = 'relative';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
canvas.style.aspectRatio = '16/9';
|
||||
@@ -1448,17 +1558,14 @@ function start2DMode() {
|
||||
canvas.style.display = '';
|
||||
|
||||
// Create video texture if not exists
|
||||
if (videoTexture) videoTexture.dispose();
|
||||
videoTexture = new THREE.VideoTexture(video);
|
||||
videoTexture.minFilter = THREE.LinearFilter;
|
||||
videoTexture.magFilter = THREE.LinearFilter;
|
||||
videoTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
videoTexture = createVideoTexture();
|
||||
positionPlaneForPresentation(projectionMode === 'plane');
|
||||
|
||||
// Apply texture to sphere material and make mesh visible
|
||||
if (sphereMaterial && vr180Mesh) {
|
||||
// Apply texture to the selected projection mesh and make it visible
|
||||
if (sphereMaterial && activeContentMesh) {
|
||||
sphereMaterial.map = videoTexture;
|
||||
sphereMaterial.needsUpdate = true;
|
||||
vr180Mesh.visible = true;
|
||||
showActiveContentMesh();
|
||||
}
|
||||
|
||||
// Start video playback
|
||||
@@ -1484,21 +1591,29 @@ function start2DMode() {
|
||||
}
|
||||
|
||||
function add2DEventListeners() {
|
||||
// Canvas-specific mouse movement for showing controls
|
||||
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
if (projectionMode === 'vr180') {
|
||||
// Mouse events
|
||||
renderer.domElement.addEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Canvas-specific mouse movement for showing controls
|
||||
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
// 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() {
|
||||
if (!renderer || !renderer.domElement) return;
|
||||
|
||||
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
// Mouse events
|
||||
renderer.domElement.removeEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.removeEventListener('mousemove', onMouseMove);
|
||||
@@ -1508,6 +1623,7 @@ function remove2DEventListeners() {
|
||||
renderer.domElement.removeEventListener('touchstart', onTouchStart);
|
||||
renderer.domElement.removeEventListener('touchmove', onTouchMove);
|
||||
renderer.domElement.removeEventListener('touchend', onTouchEnd);
|
||||
renderer.domElement.removeEventListener('touchstart', onCanvasTouchStart);
|
||||
|
||||
// Fullscreen events
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
@@ -1576,16 +1692,15 @@ async function actualSessionToggle() {
|
||||
}
|
||||
|
||||
if (camera) camera.updateProjectionMatrix();
|
||||
positionPlaneForPresentation(false);
|
||||
|
||||
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
|
||||
if (video) {
|
||||
videoTexture = new THREE.VideoTexture(video);
|
||||
videoTexture.minFilter = THREE.LinearFilter; videoTexture.magFilter = THREE.LinearFilter;
|
||||
videoTexture.colorSpace = THREE.SRGBColorSpace;
|
||||
if (vr180Mesh && sphereMaterial) {
|
||||
videoTexture = createVideoTexture();
|
||||
if (activeContentMesh && sphereMaterial) {
|
||||
sphereMaterial.map = videoTexture;
|
||||
sphereMaterial.needsUpdate = true;
|
||||
vr180Mesh.visible = true;
|
||||
showActiveContentMesh();
|
||||
} else { throw new Error("VR mesh components not ready for texture."); }
|
||||
} else {
|
||||
throw new Error("Video element not available for creating texture.");
|
||||
@@ -1615,7 +1730,7 @@ async function actualSessionToggle() {
|
||||
console.error(sessionStartError, err);
|
||||
isXrLoopActive = false;
|
||||
|
||||
if (vr180Mesh) vr180Mesh.visible = false;
|
||||
hideContentMeshes();
|
||||
if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
|
||||
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
|
||||
if (vrControlPanel) {
|
||||
@@ -1666,7 +1781,7 @@ function onVRSessionEnd(event) {
|
||||
if (videoTexture) {
|
||||
videoTexture.dispose(); videoTexture = null;
|
||||
}
|
||||
if (vr180Mesh) vr180Mesh.visible = false;
|
||||
hideContentMeshes();
|
||||
if (vrControlPanel) {
|
||||
clearTimeout(panelHideTimeout);
|
||||
isPanelFading = false;
|
||||
@@ -1733,6 +1848,10 @@ function renderXR(timestamp, frame) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Sync video texture before render to ensure frame consistency
|
||||
if (videoTexture && video && !video.paused && !video.ended) {
|
||||
videoTexture.needsUpdate = true;
|
||||
}
|
||||
handleControllerInteractions();
|
||||
renderer.render(scene, camera);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user