forked from EXT/VR180-Web-Player
Typescript conversion
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
12
README.md
12
README.md
@@ -7,7 +7,7 @@ The player supports two projection modes:
|
|||||||
- `plane`: a flat stereoscopic video plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
|
- `plane`: a flat stereoscopic video plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
|
||||||
|
|
||||||
## How to use it
|
## How to use it
|
||||||
Include the module script from your CDN. The script automatically loads its matching CSS file and image assets from the same folder.
|
Host the whole `vr180player/` directory on your CDN, then 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
|
```html
|
||||||
<div data-vr-web-player data-projection="vr180">
|
<div data-vr-web-player data-projection="vr180">
|
||||||
@@ -43,3 +43,13 @@ When the page loads, the video is embedded normally with a play button over the
|
|||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
Open this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
|
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/`. Build the CDN-ready JavaScript files into `vr180player/` with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the TypeScript source files rather than the generated JavaScript files in `vr180player/`.
|
||||||
|
|||||||
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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
1639
src/vr180player/vr180-player.ts
Normal file
1639
src/vr180player/vr180-player.ts
Normal file
File diff suppressed because it is too large
Load Diff
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"]
|
||||||
|
}
|
||||||
7
vr180player/config.js
Normal file
7
vr180player/config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||||
|
export const DEFAULT_PROJECTION = 'vr180';
|
||||||
|
export const VALID_PROJECTIONS = new Set(['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;
|
||||||
86
vr180player/dom.js
Normal file
86
vr180player/dom.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
export function injectPlayerStyles(playerBase) {
|
||||||
|
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) {
|
||||||
|
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() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
62
vr180player/projection.js
Normal file
62
vr180player/projection.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export 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;
|
||||||
|
}
|
||||||
|
export function applySbsTextureWindow(renderingRenderer, activeCamera, material, is2DMode) {
|
||||||
|
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, planeMesh) {
|
||||||
|
if (vr180Mesh)
|
||||||
|
vr180Mesh.visible = false;
|
||||||
|
if (planeMesh)
|
||||||
|
planeMesh.visible = false;
|
||||||
|
}
|
||||||
|
export function showActiveContentMesh(vr180Mesh, planeMesh, activeContentMesh) {
|
||||||
|
hideContentMeshes(vr180Mesh, planeMesh);
|
||||||
|
if (activeContentMesh) {
|
||||||
|
activeContentMesh.visible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function positionPlaneForPresentation(planeMesh, camera2D, isFallback2D, planeDistance, plane2DDistance) {
|
||||||
|
if (!planeMesh)
|
||||||
|
return;
|
||||||
|
const zPosition = isFallback2D && camera2D ? camera2D.position.z - plane2DDistance : -planeDistance;
|
||||||
|
planeMesh.position.set(0, 1.6, zPosition);
|
||||||
|
}
|
||||||
90
vr180player/three-utils.js
Normal file
90
vr180player/three-utils.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||||
|
export function drawRoundedRect(ctx, x, y, width, height, radius = 5, fill, stroke = true) {
|
||||||
|
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, 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) {
|
||||||
|
const texture = new THREE.VideoTexture(video);
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user