From 481ca9fc4754dc71d186ace868f7f6052423939d Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:27:12 +1000 Subject: [PATCH] more refactor --- package.json | 2 +- src/vr180player/bootstrap.ts | 99 +++++++++++++ src/vr180player/rendering/texture-manager.ts | 67 +++++++++ src/vr180player/vr180-player.ts | 142 +++++-------------- tests/texture-manager.test.mjs | 73 ++++++++++ 5 files changed, 272 insertions(+), 111 deletions(-) create mode 100644 src/vr180player/bootstrap.ts create mode 100644 src/vr180player/rendering/texture-manager.ts create mode 100644 tests/texture-manager.test.mjs diff --git a/package.json b/package.json index 97bbd2d..c3da479 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/vr180player/bootstrap.ts b/src/vr180player/bootstrap.ts new file mode 100644 index 0000000..417796a --- /dev/null +++ b/src/vr180player/bootstrap.ts @@ -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(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; +} diff --git a/src/vr180player/rendering/texture-manager.ts b/src/vr180player/rendering/texture-manager.ts new file mode 100644 index 0000000..0932ed7 --- /dev/null +++ b/src/vr180player/rendering/texture-manager.ts @@ -0,0 +1,67 @@ +export type ManagedTexture = { + needsUpdate?: boolean; + dispose(): void; +}; + +export type ManagedMaterial = { + map: TTexture | null; + needsUpdate: boolean; +}; + +export type TextureFactory = (video: HTMLVideoElement) => TTexture; + +export class VideoTextureManager { + private texture: TTexture | null = null; + private readonly createTexture: TextureFactory; + private readonly video: HTMLVideoElement; + + constructor(video: HTMLVideoElement, createTexture: TextureFactory) { + this.createTexture = createTexture; + this.video = video; + } + + get current(): TTexture | null { + return this.texture; + } + + assignToMaterial(material: ManagedMaterial): TTexture { + const texture = this.create(); + material.map = texture; + material.needsUpdate = true; + return texture; + } + + clearMaterial(material?: ManagedMaterial | 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; + } + } +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index 9325a05..23bb4a1 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -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)); diff --git a/tests/texture-manager.test.mjs b/tests/texture-manager.test.mjs new file mode 100644 index 0000000..c24f0a3 --- /dev/null +++ b/tests/texture-manager.test.mjs @@ -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); +});