1
0

2 Commits

Author SHA1 Message Date
Aiden
36986ae639 removed built files 2026-06-10 10:37:40 +10:00
Aiden
d24e2021f2 Typescript conversion 2026-06-10 10:35:14 +10:00
12 changed files with 455 additions and 275 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
# Generated by `npm run build`.
vr180player/*.js

View File

@@ -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.
## 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.
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">
@@ -42,4 +42,14 @@ When the page loads, the video is embedded normally with a play button over the
- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image.
## Demo
Open this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes.
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.

29
package-lock.json generated Normal file
View 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
View 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"
}
}

Binary file not shown.

11
src/vr180player/config.ts Normal file
View 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
View 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;
}

View 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);
}

View 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
View 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>;
};
}

View File

@@ -1,14 +1,30 @@
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;
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 playerContainer, projectionMode: ProjectionMode = DEFAULT_PROJECTION;
let scene, camera, renderer, video, videoTexture, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
@@ -113,123 +129,7 @@ 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 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.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;
}
function create2DControlPanel() {
const panel = document.createElement('div');
panel.className = 'vrwp-panel';
// Status section
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);
// Controls section
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);
// Assemble panel
panel.appendChild(status);
panel.appendChild(controls);
return panel;
}
injectPlayerStyles();
injectPlayerStyles(_playerBase);
document.addEventListener('DOMContentLoaded', () => {
const containers = document.querySelectorAll(PLAYER_SELECTOR);
@@ -247,11 +147,12 @@ document.addEventListener('DOMContentLoaded', () => {
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".`);
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) {
@@ -261,7 +162,7 @@ document.addEventListener('DOMContentLoaded', () => {
videoElement.classList.add('vrwp-video');
// Create and insert play button
playBtn = createPlayButton();
playBtn = createPlayButton(_playerBase);
playerContainer.appendChild(playBtn);
// Create and insert 2D control panel
@@ -304,164 +205,25 @@ 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 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 (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;
}
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;
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DMode);
}
function hideContentMeshes() {
if (vr180Mesh) vr180Mesh.visible = false;
if (planeMesh) planeMesh.visible = false;
hideContentMeshesCore(vr180Mesh, planeMesh);
}
function showActiveContentMesh() {
hideContentMeshes();
if (activeContentMesh) {
activeContentMesh.visible = true;
}
showActiveContentMeshCore(vr180Mesh, planeMesh, activeContentMesh);
}
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);
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
}
function createVideoTexture() {
if (videoTexture) videoTexture.dispose();
videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
videoTexture.colorSpace = THREE.SRGBColorSpace;
videoTexture = createVideoTextureCore(video);
return videoTexture;
}

16
tsconfig.json Normal file
View 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"]
}