forked from EXT/VR180-Web-Player
Compare commits
17 Commits
fix/quest-
...
36986ae639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36986ae639 | ||
|
|
d24e2021f2 | ||
|
|
91b612785b | ||
|
|
3bd2c135a9 | ||
|
|
2194a4726e | ||
|
|
858eb62947 | ||
|
|
d52a722ce7 | ||
|
|
40e3711925 | ||
|
|
c41ad12b32 | ||
|
|
dbe6e5b1d9 | ||
|
|
626b7f451b | ||
|
|
a56e36eaf6 | ||
|
|
bebcb3d355 | ||
|
|
f4fb9cf6bb | ||
|
|
38200c82f2 | ||
|
|
715e762fc9 | ||
|
|
065e8310e3 |
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
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
|
||||
# Generated by `npm run build`.
|
||||
vr180player/*.js
|
||||
67
README.md
67
README.md
@@ -1,38 +1,55 @@
|
||||
# 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? 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
|
||||
1. Drop the `vr180player` directory in the root level of your website.
|
||||
2. Link to the player CSS file `<link rel="stylesheet" href="vr180player/vr180-player.css">`.
|
||||
3. Add the player script `<script type="module" src="vr180player/vr180-player.js"></script>` before the closing body tag.
|
||||
4. And use this HTML snippet to embed your video:
|
||||
Build the player first, then host the generated `vr180player/` directory on your CDN and include the module script. The script automatically loads its matching CSS file and image assets from the same folder, and it imports its helper modules with relative module paths.
|
||||
|
||||
```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">
|
||||
</video>
|
||||
</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
|
||||
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**
|
||||

|
||||
|
||||
**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.
|
||||
- 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!**
|
||||
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)
|
||||
Run `npm run build`, then open this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
|
||||
|
||||
## Development
|
||||
The player source is TypeScript in `src/vr180player/`. Generated JavaScript files in `vr180player/` are ignored by git so CI/CD can build and publish them from source.
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Edit the TypeScript source files rather than generated JavaScript. A typical CI/CD publish step should run `npm ci`, `npm run build`, then publish `vr180player/` with its generated `.js` files, CSS, and images.
|
||||
|
||||
25
index.html
25
index.html
@@ -3,31 +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-size: 1rem;
|
||||
font-weight: normal;
|
||||
body {
|
||||
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 750px;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</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>
|
||||
|
||||
29
package-lock.json
generated
Normal file
29
package-lock.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "vr-web-player",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vr-web-player",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "vr-web-player",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
BIN
sbs-video.mp4
BIN
sbs-video.mp4
Binary file not shown.
11
src/vr180player/config.ts
Normal file
11
src/vr180player/config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||
|
||||
export type ProjectionMode = 'vr180' | 'plane';
|
||||
|
||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||
|
||||
export const PLANE_WIDTH = 3.2;
|
||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
||||
export const PLANE_DISTANCE = 3;
|
||||
export const PLANE_2D_DISTANCE = 1.2;
|
||||
112
src/vr180player/dom.ts
Normal file
112
src/vr180player/dom.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export function injectPlayerStyles(playerBase: string): void {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlayButton(playerBase: string): HTMLButtonElement {
|
||||
const playButton = document.createElement('button');
|
||||
playButton.type = 'button';
|
||||
playButton.className = 'vrwp-play-button';
|
||||
playButton.setAttribute('aria-label', 'Play video');
|
||||
|
||||
const playImg = document.createElement('img');
|
||||
playImg.src = playerBase + 'images/play.png';
|
||||
playImg.alt = 'Play';
|
||||
|
||||
playButton.appendChild(playImg);
|
||||
|
||||
return playButton;
|
||||
}
|
||||
|
||||
export function create2DControlPanel(): HTMLDivElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'vrwp-panel';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'vrwp-status';
|
||||
|
||||
const videoTitle = document.createElement('p');
|
||||
videoTitle.className = 'vrwp-video-title';
|
||||
videoTitle.textContent = 'Title';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'vrwp-progress';
|
||||
|
||||
const currentTime = document.createElement('p');
|
||||
currentTime.className = 'vrwp-current-time';
|
||||
currentTime.textContent = '00:00:00';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'vrwp-bar';
|
||||
|
||||
const played = document.createElement('div');
|
||||
played.className = 'vrwp-played';
|
||||
bar.appendChild(played);
|
||||
|
||||
const totalTime = document.createElement('p');
|
||||
totalTime.className = 'vrwp-total-time';
|
||||
totalTime.textContent = '00:00:00';
|
||||
|
||||
progress.appendChild(currentTime);
|
||||
progress.appendChild(bar);
|
||||
progress.appendChild(totalTime);
|
||||
|
||||
status.appendChild(videoTitle);
|
||||
status.appendChild(progress);
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'vrwp-controls';
|
||||
|
||||
const fullscreenBtn = document.createElement('button');
|
||||
fullscreenBtn.type = 'button';
|
||||
fullscreenBtn.className = 'vrwp-fullscreen';
|
||||
fullscreenBtn.setAttribute('aria-label', 'Toggle fullscreen');
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.className = 'vrwp-nav';
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.type = 'button';
|
||||
backBtn.className = 'vrwp-back';
|
||||
backBtn.setAttribute('aria-label', 'Back 15 seconds');
|
||||
|
||||
const play2Btn = document.createElement('button');
|
||||
play2Btn.type = 'button';
|
||||
play2Btn.className = 'vrwp-play-toggle';
|
||||
play2Btn.setAttribute('aria-label', 'Play or pause');
|
||||
|
||||
const forwardBtn = document.createElement('button');
|
||||
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.type = 'button';
|
||||
muteBtn.className = 'vrwp-mute';
|
||||
muteBtn.setAttribute('aria-label', 'Toggle mute');
|
||||
|
||||
controls.appendChild(fullscreenBtn);
|
||||
controls.appendChild(nav);
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
panel.appendChild(status);
|
||||
panel.appendChild(controls);
|
||||
|
||||
return panel;
|
||||
}
|
||||
80
src/vr180player/projection.ts
Normal file
80
src/vr180player/projection.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export function isLeftEyeCamera(renderingRenderer: any, activeCamera: any): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
export function applySbsTextureWindow(
|
||||
renderingRenderer: any,
|
||||
activeCamera: any,
|
||||
material: any,
|
||||
is2DMode: boolean
|
||||
): void {
|
||||
if (!material.map) return;
|
||||
const isPresentingXR = renderingRenderer.xr.isPresenting;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function hideContentMeshes(vr180Mesh: any, planeMesh: any): void {
|
||||
if (vr180Mesh) vr180Mesh.visible = false;
|
||||
if (planeMesh) planeMesh.visible = false;
|
||||
}
|
||||
|
||||
export function showActiveContentMesh(vr180Mesh: any, planeMesh: any, activeContentMesh: any): void {
|
||||
hideContentMeshes(vr180Mesh, planeMesh);
|
||||
if (activeContentMesh) {
|
||||
activeContentMesh.visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function positionPlaneForPresentation(
|
||||
planeMesh: any,
|
||||
camera2D: any,
|
||||
isFallback2D: boolean,
|
||||
planeDistance: number,
|
||||
plane2DDistance: number
|
||||
): void {
|
||||
if (!planeMesh) return;
|
||||
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
|
||||
planeMesh.position.set(0, 1.6, zPosition);
|
||||
}
|
||||
116
src/vr180player/three-utils.ts
Normal file
116
src/vr180player/three-utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
|
||||
type Radius = number | {
|
||||
tl?: number;
|
||||
tr?: number;
|
||||
br?: number;
|
||||
bl?: number;
|
||||
};
|
||||
|
||||
export function drawRoundedRect(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: Radius = 5,
|
||||
fill: boolean | string,
|
||||
stroke: boolean | string = true
|
||||
): void {
|
||||
let corners;
|
||||
if (typeof radius === 'number') {
|
||||
corners = { tl: radius, tr: radius, br: radius, bl: radius };
|
||||
} else {
|
||||
corners = { tl: 0, tr: 0, br: 0, bl: 0, ...radius };
|
||||
}
|
||||
|
||||
if (width < 2 * corners.tl) corners.tl = width / 2;
|
||||
if (width < 2 * corners.tr) corners.tr = width / 2;
|
||||
if (width < 2 * corners.bl) corners.bl = width / 2;
|
||||
if (width < 2 * corners.br) corners.br = width / 2;
|
||||
|
||||
if (height < 2 * corners.tl) corners.tl = height / 2;
|
||||
if (height < 2 * corners.tr) corners.tr = height / 2;
|
||||
if (height < 2 * corners.bl) corners.bl = height / 2;
|
||||
if (height < 2 * corners.br) corners.br = height / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + corners.tl, y);
|
||||
ctx.lineTo(x + width - corners.tr, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + corners.tr);
|
||||
ctx.lineTo(x + width, y + height - corners.br);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - corners.br, y + height);
|
||||
ctx.lineTo(x + corners.bl, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - corners.bl);
|
||||
ctx.lineTo(x, y + corners.tl);
|
||||
ctx.quadraticCurveTo(x, y, x + corners.tl, y);
|
||||
ctx.closePath();
|
||||
|
||||
if (fill) {
|
||||
if (typeof fill === 'string') ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (stroke) {
|
||||
if (typeof stroke === 'string') ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
export function createButtonTexture(
|
||||
textOrPathData: string,
|
||||
textColor = 'white',
|
||||
backgroundColor = 'rgba(0,0,0,0)',
|
||||
textureSize = 128,
|
||||
fontSize = 48,
|
||||
isSvgPath = false,
|
||||
svgViewBoxSize = 44
|
||||
) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = textureSize;
|
||||
canvas.height = textureSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('Unable to create 2D canvas context for button texture.');
|
||||
}
|
||||
|
||||
if (backgroundColor !== 'rgba(0,0,0,0)') {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, textureSize, textureSize);
|
||||
}
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
|
||||
if (isSvgPath) {
|
||||
const path = new Path2D(textOrPathData);
|
||||
const iconTargetSize = textureSize;
|
||||
const scale = iconTargetSize / svgViewBoxSize;
|
||||
const offsetX = (textureSize - (svgViewBoxSize * scale)) / 2;
|
||||
const offsetY = (textureSize - (svgViewBoxSize * scale)) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fill(path);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(textOrPathData, textureSize / 2, textureSize / 2);
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function createVideoTexture(video: HTMLVideoElement) {
|
||||
const texture = new THREE.VideoTexture(video);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
27
src/vr180player/types.d.ts
vendored
Normal file
27
src/vr180player/types.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
declare module 'https://unpkg.com/three/build/three.module.js' {
|
||||
export const Matrix4: any;
|
||||
export const CanvasTexture: any;
|
||||
export const VideoTexture: any;
|
||||
export const LinearFilter: any;
|
||||
export const SRGBColorSpace: any;
|
||||
export const Scene: any;
|
||||
export const PerspectiveCamera: any;
|
||||
export const WebGLRenderer: any;
|
||||
export const SphereGeometry: any;
|
||||
export const MeshBasicMaterial: any;
|
||||
export const Mesh: any;
|
||||
export const PlaneGeometry: any;
|
||||
export const Group: any;
|
||||
export const Raycaster: any;
|
||||
export const LineBasicMaterial: any;
|
||||
export const BufferGeometry: any;
|
||||
export const Vector3: any;
|
||||
export const Line: any;
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
xr?: {
|
||||
isSessionSupported(mode: string): Promise<boolean>;
|
||||
requestSession(mode: string, options?: unknown): Promise<any>;
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,32 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import {
|
||||
DEFAULT_PROJECTION,
|
||||
PLANE_2D_DISTANCE,
|
||||
PLANE_DISTANCE,
|
||||
PLANE_HEIGHT,
|
||||
PLANE_WIDTH,
|
||||
PLAYER_SELECTOR,
|
||||
type ProjectionMode,
|
||||
VALID_PROJECTIONS
|
||||
} from './config.js';
|
||||
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom.js';
|
||||
import {
|
||||
applySbsTextureWindow as applySbsTextureWindowCore,
|
||||
hideContentMeshes as hideContentMeshesCore,
|
||||
positionPlaneForPresentation as positionPlaneForPresentationCore,
|
||||
showActiveContentMesh as showActiveContentMeshCore
|
||||
} from './projection.js';
|
||||
import {
|
||||
createButtonTexture,
|
||||
createVideoTexture as createVideoTextureCore,
|
||||
drawRoundedRect
|
||||
} from './three-utils.js';
|
||||
|
||||
const _playerBase = new URL('.', import.meta.url).href;
|
||||
|
||||
let playerContainer, projectionMode: ProjectionMode = DEFAULT_PROJECTION;
|
||||
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();
|
||||
@@ -104,117 +129,45 @@ const SOUND_ON_SVG_PATH = "M13.4844 0.956299C13.766 0.689292 14.2002 0.683249 14
|
||||
const SOUND_MUTED_SVG_PATH = "M6.9082 2.8985C7.71639 2.45747 8.74994 3.03437 8.75 4.00006V14.0001C8.75 15.0301 7.57405 15.6181 6.75 15.0001L3.64746 12.6729L1.10449 11.8253C0.594245 11.655 0.250012 11.1776 0.25 10.6397V7.36041C0.25005 6.82252 0.594258 6.34508 1.10449 6.17486L3.64746 5.32623L6.75 3.00006L6.9082 2.8985ZM4.51465 6.55182C4.4341 6.61218 4.34631 6.66193 4.25391 6.70123L4.16016 6.73736L1.75 7.5401V10.459L4.16016 11.2628L4.25391 11.2989C4.31551 11.3251 4.37502 11.356 4.43164 11.3917L4.51465 11.4483L7.25 13.4991V4.50006L4.51465 6.55182ZM9.60156 5.53033C9.89446 5.23755 10.3693 5.23748 10.6621 5.53033L13.1318 8.00006L15.541 5.59088C15.8339 5.29821 16.3087 5.29806 16.6016 5.59088C16.8944 5.8837 16.8942 6.35852 16.6016 6.65143L14.1924 9.06061L16.6729 11.5411C16.9654 11.834 16.9655 12.3088 16.6729 12.6016C16.38 12.8945 15.9042 12.8945 15.6113 12.6016L13.1309 10.1212L10.5908 12.6622C10.2979 12.9549 9.82313 12.955 9.53027 12.6622C9.23742 12.3693 9.23749 11.8945 9.53027 11.6016L12.0703 9.06061L9.60156 6.59088C9.30867 6.29799 9.30867 5.82323 9.60156 5.53033Z";
|
||||
|
||||
|
||||
// Dynamic UI Creation Functions
|
||||
function createPlayButton() {
|
||||
const playButton = document.createElement('button');
|
||||
playButton.id = 'playBtn';
|
||||
playButton.setAttribute('aria-label', 'Play video');
|
||||
|
||||
const playImg = document.createElement('img');
|
||||
playImg.src = 'vr180player/images/play.png';
|
||||
playImg.alt = 'Play';
|
||||
|
||||
playButton.appendChild(playImg);
|
||||
|
||||
return playButton;
|
||||
}
|
||||
|
||||
function create2DControlPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'panel';
|
||||
|
||||
// Status section
|
||||
const status = document.createElement('div');
|
||||
status.id = 'status';
|
||||
|
||||
const videoTitle = document.createElement('p');
|
||||
videoTitle.id = 'video-title';
|
||||
videoTitle.textContent = 'Title';
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.id = 'progress';
|
||||
|
||||
const currentTime = document.createElement('p');
|
||||
currentTime.id = 'current-time';
|
||||
currentTime.textContent = '00:00:00';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'bar';
|
||||
|
||||
const played = document.createElement('div');
|
||||
played.id = 'played';
|
||||
bar.appendChild(played);
|
||||
|
||||
const totalTime = document.createElement('p');
|
||||
totalTime.id = 'total-time';
|
||||
totalTime.textContent = '00:00:00';
|
||||
|
||||
progress.appendChild(currentTime);
|
||||
progress.appendChild(bar);
|
||||
progress.appendChild(totalTime);
|
||||
|
||||
status.appendChild(videoTitle);
|
||||
status.appendChild(progress);
|
||||
|
||||
// Controls section
|
||||
const controls = document.createElement('div');
|
||||
controls.id = 'controls';
|
||||
|
||||
const fullscreenBtn = document.createElement('button');
|
||||
fullscreenBtn.id = 'fullscreen';
|
||||
|
||||
const nav = document.createElement('div');
|
||||
nav.id = 'nav';
|
||||
|
||||
const backBtn = document.createElement('button');
|
||||
backBtn.id = 'back';
|
||||
|
||||
const play2Btn = document.createElement('button');
|
||||
play2Btn.id = 'play2';
|
||||
|
||||
const forwardBtn = document.createElement('button');
|
||||
forwardBtn.id = 'forward';
|
||||
|
||||
nav.appendChild(backBtn);
|
||||
nav.appendChild(play2Btn);
|
||||
nav.appendChild(forwardBtn);
|
||||
|
||||
const muteBtn = document.createElement('button');
|
||||
muteBtn.id = 'mute';
|
||||
|
||||
controls.appendChild(fullscreenBtn);
|
||||
controls.appendChild(nav);
|
||||
controls.appendChild(muteBtn);
|
||||
|
||||
// Assemble panel
|
||||
panel.appendChild(status);
|
||||
panel.appendChild(controls);
|
||||
|
||||
return panel;
|
||||
}
|
||||
injectPlayerStyles(_playerBase);
|
||||
|
||||
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');
|
||||
|
||||
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
|
||||
return;
|
||||
}
|
||||
projectionMode = configuredProjection as ProjectionMode;
|
||||
|
||||
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);
|
||||
playBtn = createPlayButton(_playerBase);
|
||||
playerContainer.appendChild(playBtn);
|
||||
|
||||
// Create and insert 2D control panel
|
||||
const controlPanel = create2DControlPanel();
|
||||
container.appendChild(controlPanel);
|
||||
playerContainer.appendChild(controlPanel);
|
||||
|
||||
playBtn.disabled = true;
|
||||
|
||||
@@ -252,85 +205,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
function drawRoundedRect(ctx, x, y, width, height, radius, fill, stroke) {
|
||||
if (typeof stroke === 'undefined') { stroke = true; }
|
||||
if (typeof radius === 'undefined') { radius = 5; }
|
||||
if (typeof radius === 'number') {
|
||||
radius = { tl: radius, tr: radius, br: radius, bl: radius };
|
||||
} else {
|
||||
const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 };
|
||||
for (let side in defaultRadius) {
|
||||
radius[side] = radius[side] || defaultRadius[side];
|
||||
}
|
||||
}
|
||||
const minDimension = Math.min(width, height);
|
||||
if (width < 2 * radius.tl) radius.tl = width / 2;
|
||||
if (width < 2 * radius.tr) radius.tr = width / 2;
|
||||
if (width < 2 * radius.bl) radius.bl = width / 2;
|
||||
if (width < 2 * radius.br) radius.br = width / 2;
|
||||
|
||||
if (height < 2 * radius.tl) radius.tl = height / 2;
|
||||
if (height < 2 * radius.tr) radius.tr = height / 2;
|
||||
if (height < 2 * radius.bl) radius.bl = height / 2;
|
||||
if (height < 2 * radius.br) radius.br = height / 2;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius.tl, y);
|
||||
ctx.lineTo(x + width - radius.tr, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr);
|
||||
ctx.lineTo(x + width, y + height - radius.br);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height);
|
||||
ctx.lineTo(x + radius.bl, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl);
|
||||
ctx.lineTo(x, y + radius.tl);
|
||||
ctx.quadraticCurveTo(x, y, x + radius.tl, y);
|
||||
ctx.closePath();
|
||||
if (fill) {
|
||||
if (typeof fill === 'string') ctx.fillStyle = fill;
|
||||
ctx.fill();
|
||||
}
|
||||
if (stroke) {
|
||||
if (typeof stroke === 'string') ctx.strokeStyle = stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
function applySbsTextureWindow(renderingRenderer, activeCamera, material) {
|
||||
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DMode);
|
||||
}
|
||||
|
||||
function createButtonTexture(textOrPathData, textColor = 'white', backgroundColor = 'rgba(0,0,0,0)', textureSize = 128, fontSize = 48, isSvgPath = false, svgViewBoxSize = 44) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = textureSize;
|
||||
canvas.height = textureSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
function hideContentMeshes() {
|
||||
hideContentMeshesCore(vr180Mesh, planeMesh);
|
||||
}
|
||||
|
||||
if (backgroundColor !== 'rgba(0,0,0,0)') {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, textureSize, textureSize);
|
||||
}
|
||||
function showActiveContentMesh() {
|
||||
showActiveContentMeshCore(vr180Mesh, planeMesh, activeContentMesh);
|
||||
}
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
function positionPlaneForPresentation(isFallback2D = false) {
|
||||
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
|
||||
}
|
||||
|
||||
if (isSvgPath) {
|
||||
const path = new Path2D(textOrPathData);
|
||||
const iconTargetSize = textureSize;
|
||||
const scale = iconTargetSize / svgViewBoxSize;
|
||||
const offsetX = (textureSize - (svgViewBoxSize * scale)) / 2;
|
||||
const offsetY = (textureSize - (svgViewBoxSize * scale)) / 2;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(offsetX, offsetY);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.fill(path);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.font = `bold ${fontSize}px Helvetica, Arial, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(textOrPathData, textureSize / 2, textureSize / 2);
|
||||
}
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
function createVideoTexture() {
|
||||
if (videoTexture) videoTexture.dispose();
|
||||
videoTexture = createVideoTextureCore(video);
|
||||
return videoTexture;
|
||||
}
|
||||
|
||||
|
||||
@@ -348,7 +242,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 +266,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,51 +293,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;
|
||||
}
|
||||
|
||||
// Use view matrix eye offset for reliable stereo detection
|
||||
// This works consistently across Quest Browser updates and Safari/VisionOS
|
||||
// Left eye has negative X offset, right eye has positive X offset
|
||||
const viewMatrix = activeCamera.matrixWorldInverse;
|
||||
const eyeOffsetX = viewMatrix.elements[12];
|
||||
|
||||
if (eyeOffsetX < 0) {
|
||||
// Left eye - show left half of SBS video
|
||||
material.map.offset.x = 0;
|
||||
} else {
|
||||
// Right eye - show right half of SBS video
|
||||
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);
|
||||
@@ -828,7 +699,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;
|
||||
@@ -979,7 +850,11 @@ function onTouchEnd(event) {
|
||||
function render2D() {
|
||||
if (!is2DMode) return;
|
||||
|
||||
updateCameraRotation();
|
||||
if (projectionMode === 'vr180') {
|
||||
updateCameraRotation();
|
||||
} else if (camera2D) {
|
||||
camera2D.rotation.set(0, 0, 0);
|
||||
}
|
||||
|
||||
if (renderer && camera2D && scene) {
|
||||
renderer.render(scene, camera2D);
|
||||
@@ -991,17 +866,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");
|
||||
@@ -1066,9 +941,6 @@ function init2DControlPanel() {
|
||||
show2DControlPanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse movement listener will be added to canvas in start2DMode
|
||||
document.addEventListener('touchstart', on2DTouchStart);
|
||||
}
|
||||
|
||||
function show2DControlPanel() {
|
||||
@@ -1095,6 +967,12 @@ function onCanvasMouseMove() {
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasTouchStart() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function on2DMouseMove() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
@@ -1155,7 +1033,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);
|
||||
@@ -1195,7 +1073,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;
|
||||
@@ -1296,6 +1174,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) {
|
||||
@@ -1369,7 +1248,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();
|
||||
@@ -1408,7 +1287,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;
|
||||
@@ -1441,17 +1320,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
|
||||
@@ -1477,21 +1353,29 @@ function start2DMode() {
|
||||
}
|
||||
|
||||
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
|
||||
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 });
|
||||
if (projectionMode === 'vr180') {
|
||||
// Mouse events
|
||||
renderer.domElement.addEventListener('mousedown', onMouseDown);
|
||||
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() {
|
||||
if (!renderer || !renderer.domElement) return;
|
||||
|
||||
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
// Mouse events
|
||||
renderer.domElement.removeEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.removeEventListener('mousemove', onMouseMove);
|
||||
@@ -1501,6 +1385,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);
|
||||
@@ -1569,16 +1454,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.");
|
||||
@@ -1608,7 +1492,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) {
|
||||
@@ -1659,7 +1543,7 @@ function onVRSessionEnd(event) {
|
||||
if (videoTexture) {
|
||||
videoTexture.dispose(); videoTexture = null;
|
||||
}
|
||||
if (vr180Mesh) vr180Mesh.visible = false;
|
||||
hideContentMeshes();
|
||||
if (vrControlPanel) {
|
||||
clearTimeout(panelHideTimeout);
|
||||
isPanelFading = false;
|
||||
@@ -1726,12 +1610,10 @@ function renderXR(timestamp, frame) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Ensure video texture is synchronized with render loop
|
||||
// This prevents glitches from texture update timing issues on Quest browsers
|
||||
// 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) {
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"rootDir": "src/vr180player",
|
||||
"outDir": "vr180player",
|
||||
"allowJs": false,
|
||||
"checkJs": false,
|
||||
"strict": false,
|
||||
"noEmitOnError": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"]
|
||||
},
|
||||
"include": ["src/vr180player/**/*.ts", "src/vr180player/**/*.d.ts"]
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
#vr-container {
|
||||
.vrwp {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
video {
|
||||
.vrwp-video,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 16/9;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
#playBtn {
|
||||
.vrwp-play-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
@@ -25,41 +26,39 @@ video {
|
||||
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%;
|
||||
@@ -69,7 +68,7 @@ video {
|
||||
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;
|
||||
@@ -77,38 +76,38 @@ video {
|
||||
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;
|
||||
@@ -117,7 +116,7 @@ video {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#played {
|
||||
.vrwp-played {
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
height: 4px;
|
||||
@@ -125,20 +124,20 @@ video {
|
||||
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;
|
||||
@@ -149,11 +148,11 @@ video {
|
||||
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;
|
||||
@@ -164,27 +163,27 @@ video {
|
||||
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;
|
||||
@@ -192,7 +191,7 @@ video {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
#back {
|
||||
.vrwp-back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-image: url(images/back.png);
|
||||
@@ -202,11 +201,11 @@ video {
|
||||
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);
|
||||
@@ -216,27 +215,27 @@ video {
|
||||
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);
|
||||
@@ -246,6 +245,6 @@ video {
|
||||
transition: background-image 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
#forward:hover {
|
||||
.vrwp-forward:hover {
|
||||
background-image: url(images/forward-hover.png);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user