1
0

more refactor
All checks were successful
Test / test (push) Successful in 9m26s

This commit is contained in:
Aiden
2026-06-10 12:27:12 +10:00
parent ca577d2e92
commit 481ca9fc47
5 changed files with 272 additions and 111 deletions

View File

@@ -7,7 +7,7 @@
"dev": "npm run build && vite --host 127.0.0.1",
"build": "tsc",
"check": "tsc --noEmit",
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs",
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs",
"preview": "npm run build && vite preview --host 127.0.0.1"
},
"devDependencies": {

View File

@@ -0,0 +1,99 @@
import {
DEFAULT_PROJECTION,
PLAYER_SELECTOR,
type ProjectionMode,
VALID_PROJECTIONS
} from './config.js';
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
export type BootstrapContext = {
playButton: HTMLButtonElement;
playerContainer: HTMLElement;
projectionMode: ProjectionMode;
videoElement: HTMLVideoElement;
};
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
injectPlayerStyles(playerBase);
onDocumentReady(() => {
const containers = document.querySelectorAll<HTMLElement>(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;
}
const 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;
}
const videoElement = playerContainer.querySelector('video');
if (!videoElement) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`);
return;
}
videoElement.classList.add('vrwp-video');
const playButton = createPlayButton();
playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true;
videoElement.load();
completeXrSupportCheck(playButton, () => {
onReady({
playButton,
playerContainer,
projectionMode: configuredProjection as ProjectionMode,
videoElement
});
});
});
}
function onDocumentReady(callback: () => void): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callback, { once: true });
return;
}
callback();
}
function completeXrSupportCheck(playButton: HTMLButtonElement, onComplete: () => void): void {
if (!navigator.xr) {
markXrUnsupported(playButton);
onComplete();
return;
}
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
playButton.dataset.xrSupported = 'true';
} else {
markXrUnsupported(playButton);
}
onComplete();
}).catch((err) => {
console.error('XR Support Check Error:', err);
markXrUnsupported(playButton);
onComplete();
});
}
function markXrUnsupported(playButton: HTMLButtonElement): void {
playButton.dataset.xrSupported = 'false';
playButton.disabled = false;
}

View File

@@ -0,0 +1,67 @@
export type ManagedTexture = {
needsUpdate?: boolean;
dispose(): void;
};
export type ManagedMaterial<TTexture extends ManagedTexture = ManagedTexture> = {
map: TTexture | null;
needsUpdate: boolean;
};
export type TextureFactory<TTexture extends ManagedTexture = ManagedTexture> = (video: HTMLVideoElement) => TTexture;
export class VideoTextureManager<TTexture extends ManagedTexture = ManagedTexture> {
private texture: TTexture | null = null;
private readonly createTexture: TextureFactory<TTexture>;
private readonly video: HTMLVideoElement;
constructor(video: HTMLVideoElement, createTexture: TextureFactory<TTexture>) {
this.createTexture = createTexture;
this.video = video;
}
get current(): TTexture | null {
return this.texture;
}
assignToMaterial(material: ManagedMaterial<TTexture>): TTexture {
const texture = this.create();
material.map = texture;
material.needsUpdate = true;
return texture;
}
clearMaterial(material?: ManagedMaterial<TTexture> | null): void {
if (material?.map) {
const materialTexture = material.map;
material.map = null;
material.needsUpdate = true;
materialTexture.dispose();
if (materialTexture === this.texture) {
this.texture = null;
}
}
this.dispose();
}
create(): TTexture {
this.dispose();
this.texture = this.createTexture(this.video);
return this.texture;
}
dispose(): void {
if (this.texture) {
this.texture.dispose();
this.texture = null;
}
}
updateIfPlaying(): void {
if (this.texture && !this.video.paused && !this.video.ended) {
this.texture.needsUpdate = true;
}
}
}

View File

@@ -1,13 +1,10 @@
import {
DEFAULT_PROJECTION,
PLANE_2D_DISTANCE,
PLANE_DISTANCE,
PLAYER_SELECTOR,
type ProjectionMode,
VALID_PROJECTIONS
type ProjectionMode
} from './config.js';
import { bootstrapPlayer } from './bootstrap.js';
import { createContentScene } from './rendering/content-scene.js';
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
import {
applySbsTextureWindow as applySbsTextureWindowCore,
hideContentMeshes as hideContentMeshesCore,
@@ -35,11 +32,12 @@ import {
createPlayerRenderer,
resizePlayerRenderer
} from './rendering/renderer-lifecycle.js';
import { VideoTextureManager } from './rendering/texture-manager.js';
const _playerBase = new URL('.', import.meta.url).href;
let playerContainer, projectionMode: ProjectionMode = DEFAULT_PROJECTION;
let scene, camera, renderer, video, videoTexture, sphereMaterial;
let playerContainer, projectionMode: ProjectionMode;
let scene, camera, renderer, video, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
let raycaster, uiElements = [];
@@ -49,6 +47,7 @@ let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
let textureManager: VideoTextureManager | undefined;
let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility();
@@ -57,82 +56,15 @@ const vrPanelVisibility = new VrPanelVisibility();
let camera2D;
let fallbackCameraControls: FallbackCameraControls | undefined;
injectPlayerStyles(_playerBase);
document.addEventListener('DOMContentLoaded', () => {
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(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`);
return;
}
videoElement.classList.add('vrwp-video');
// Create and insert play button
playBtn = createPlayButton();
playerContainer.appendChild(playBtn);
// Create and insert 2D control panel
const controlPanel = create2DControlPanel();
playerContainer.appendChild(controlPanel);
playBtn.disabled = true;
if (videoElement) {
videoElement.load();
}
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
playBtn.dataset.xrSupported = "true";
} else {
playBtn.dataset.xrSupported = "false";
// Enable button for regular video playback when VR is not supported
playBtn.disabled = false;
}
// Always call init() regardless of VR support
init();
}).catch(err => {
console.error("XR Support Check Error:", err);
playBtn.dataset.xrSupported = "false";
// Enable button for regular video playback when VR check fails
playBtn.disabled = false;
// Call init() even when VR check fails
init();
});
} else {
playBtn.dataset.xrSupported = "false";
// If navigator.xr itself is not available, enable button for regular video playback
if (playBtn) {
playBtn.disabled = false;
}
// Call init() even when XR is not available
init();
}
bootstrapPlayer(_playerBase, (context) => {
playerContainer = context.playerContainer;
projectionMode = context.projectionMode;
videoElement = context.videoElement;
playBtn = context.playButton;
init();
});
function applySbsTextureWindow(renderingRenderer, activeCamera, material) {
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DModeActive());
}
@@ -150,9 +82,10 @@ function positionPlaneForPresentation(isFallback2D = false) {
}
function createVideoTexture() {
if (videoTexture) videoTexture.dispose();
videoTexture = createVideoTextureCore(video);
return videoTexture;
if (!textureManager) {
throw new Error('Video texture manager is not initialized.');
}
return textureManager.create();
}
function is2DModeActive() {
@@ -174,9 +107,7 @@ function closeActiveXrSessionAfterContextLoss() {
function restoreVideoTextureAfterContextRestored() {
if (video && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) {
videoTexture = createVideoTexture();
sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true;
textureManager?.assignToMaterial(sphereMaterial);
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
console.log("Re-initialized video texture after context restoration during VR.");
@@ -202,6 +133,7 @@ function init() {
renderer = playerRenderer.renderer;
video = videoElement;
textureManager = new VideoTextureManager(video, createVideoTextureCore);
mediaController = new MediaController({
is2DModeActive,
on2DPlaybackResume: show2DControlPanel,
@@ -502,17 +434,18 @@ async function actualSessionToggle() {
if (camera) camera.updateProjectionMatrix();
positionPlaneForPresentation(false);
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
if (video) {
videoTexture = createVideoTexture();
if (activeContentMesh && sphereMaterial) {
sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true;
showActiveContentMesh();
} else { throw new Error("VR mesh components not ready for texture."); }
} else {
textureManager?.dispose();
if (!video) {
throw new Error("Video element not available for creating texture.");
}
if (!activeContentMesh || !sphereMaterial) {
throw new Error("VR mesh components not ready for texture.");
}
if (!textureManager) {
throw new Error("Video texture manager is not initialized.");
}
textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh();
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
@@ -531,8 +464,7 @@ async function actualSessionToggle() {
isXrLoopActive = false;
hideContentMeshes();
if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
textureManager?.clearMaterial(sphereMaterial);
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
@@ -570,14 +502,7 @@ function onVRSessionEnd(event) {
mediaController?.pauseIfPlaying();
if (sphereMaterial && sphereMaterial.map) {
sphereMaterial.map.dispose();
sphereMaterial.map = null;
sphereMaterial.needsUpdate = true;
}
if (videoTexture) {
videoTexture.dispose(); videoTexture = null;
}
textureManager?.clearMaterial(sphereMaterial);
hideContentMeshes();
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
@@ -633,10 +558,7 @@ function renderXR(timestamp, frame) {
}
}
try {
// Sync video texture before render to ensure frame consistency
if (videoTexture && video && !video.paused && !video.ended) {
videoTexture.needsUpdate = true;
}
textureManager?.updateIfPlaying();
renderer.render(scene, camera);
} catch (error) {
const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error));

View File

@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { VideoTextureManager } from '../vr180player/rendering/texture-manager.js';
function createTexture(name) {
return {
disposed: false,
name,
needsUpdate: false,
dispose() {
this.disposed = true;
}
};
}
function createVideo({ paused = false, ended = false } = {}) {
return { ended, paused };
}
test('VideoTextureManager replaces the previous texture when creating a new one', () => {
const created = [];
const manager = new VideoTextureManager(createVideo(), () => {
const texture = createTexture(`texture-${created.length}`);
created.push(texture);
return texture;
});
const first = manager.create();
const second = manager.create();
assert.equal(first.disposed, true);
assert.equal(second.disposed, false);
assert.equal(manager.current, second);
assert.equal(created.length, 2);
});
test('VideoTextureManager assigns and clears material maps', () => {
const material = { map: null, needsUpdate: false };
const manager = new VideoTextureManager(createVideo(), () => createTexture('assigned'));
const texture = manager.assignToMaterial(material);
assert.equal(material.map, texture);
assert.equal(material.needsUpdate, true);
assert.equal(manager.current, texture);
material.needsUpdate = false;
manager.clearMaterial(material);
assert.equal(texture.disposed, true);
assert.equal(material.map, null);
assert.equal(material.needsUpdate, true);
assert.equal(manager.current, null);
});
test('VideoTextureManager only marks textures dirty while playback is active', () => {
const video = createVideo();
const manager = new VideoTextureManager(video, () => createTexture('playing'));
const texture = manager.create();
manager.updateIfPlaying();
assert.equal(texture.needsUpdate, true);
texture.needsUpdate = false;
video.paused = true;
manager.updateIfPlaying();
assert.equal(texture.needsUpdate, false);
video.paused = false;
video.ended = true;
manager.updateIfPlaying();
assert.equal(texture.needsUpdate, false);
});