From 24a166046ef9c7d131b43a66e054d112a8bc1ebb Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:37:48 +1000 Subject: [PATCH] more refactors --- .gitignore | 1 + package.json | 2 +- src/vr180player/bootstrap.ts | 16 ++-- src/vr180player/media/media-adapter.ts | 70 +++++++++++++++ src/vr180player/modes/two-d-mode.ts | 15 ++-- src/vr180player/rendering/texture-manager.ts | 20 +++-- src/vr180player/vr180-player.ts | 57 ++++++------ tests/media-adapter.test.mjs | 93 ++++++++++++++++++++ tests/texture-manager.test.mjs | 26 +++--- 9 files changed, 238 insertions(+), 62 deletions(-) create mode 100644 src/vr180player/media/media-adapter.ts create mode 100644 tests/media-adapter.test.mjs diff --git a/.gitignore b/.gitignore index 6ca799a..7b25949 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ # Generated by `npm run build`. vr180player/*.js vr180player/**/*.js +/media diff --git a/package.json b/package.json index f456561..ca4b391 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "npm run build && vite --host 0.0.0.0", "build": "tsc", "check": "tsc --noEmit", - "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.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 tests/media-adapter.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 index 417796a..455d9bb 100644 --- a/src/vr180player/bootstrap.ts +++ b/src/vr180player/bootstrap.ts @@ -5,12 +5,13 @@ import { VALID_PROJECTIONS } from './config.js'; import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js'; +import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js'; export type BootstrapContext = { + mediaAdapter: SupportedMediaAdapter; playButton: HTMLButtonElement; playerContainer: HTMLElement; projectionMode: ProjectionMode; - videoElement: HTMLVideoElement; }; export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void { @@ -38,25 +39,24 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap return; } - const videoElement = playerContainer.querySelector('video'); - if (!videoElement) { - console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`); + const mediaAdapter = createMediaAdapter(playerContainer); + if (!mediaAdapter) { + console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a supported media element (video).`); return; } - videoElement.classList.add('vrwp-video'); const playButton = createPlayButton(); playerContainer.appendChild(playButton); playerContainer.appendChild(create2DControlPanel()); playButton.disabled = true; - videoElement.load(); + mediaAdapter.load(); completeXrSupportCheck(playButton, () => { onReady({ + mediaAdapter, playButton, playerContainer, - projectionMode: configuredProjection as ProjectionMode, - videoElement + projectionMode: configuredProjection as ProjectionMode }); }); }); diff --git a/src/vr180player/media/media-adapter.ts b/src/vr180player/media/media-adapter.ts new file mode 100644 index 0000000..4972bef --- /dev/null +++ b/src/vr180player/media/media-adapter.ts @@ -0,0 +1,70 @@ +export type MediaCapabilities = { + audio: boolean; + dynamicTexture: boolean; + playback: boolean; + timeline: boolean; +}; + +export interface MediaAdapter { + readonly capabilities: MediaCapabilities; + readonly element: TElement; + readonly kind: string; + readonly textureSource: TTextureSource; + getTitle(): string; + hideElement(): void; + load(): void; + shouldUpdateTexture(): boolean; + showElement(): void; +} + +export type SupportedMediaAdapter = VideoMediaAdapter; + +const VIDEO_CAPABILITIES: MediaCapabilities = { + audio: true, + dynamicTexture: true, + playback: true, + timeline: true +}; + +export class VideoMediaAdapter implements MediaAdapter { + readonly capabilities = VIDEO_CAPABILITIES; + readonly kind = 'video'; + + constructor(readonly element: HTMLVideoElement) {} + + get textureSource(): HTMLVideoElement { + return this.element; + } + + getTitle(): string { + return this.element.getAttribute('title') || + this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || + 'Video Title'; + } + + hideElement(): void { + this.element.style.display = 'none'; + } + + load(): void { + this.element.load(); + } + + shouldUpdateTexture(): boolean { + return !this.element.paused && !this.element.ended; + } + + showElement(): void { + this.element.style.display = ''; + } +} + +export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null { + const videoElement = playerContainer.querySelector('video'); + if (!videoElement) { + return null; + } + + videoElement.classList.add('vrwp-video'); + return new VideoMediaAdapter(videoElement); +} diff --git a/src/vr180player/modes/two-d-mode.ts b/src/vr180player/modes/two-d-mode.ts index 259586a..b62cde6 100644 --- a/src/vr180player/modes/two-d-mode.ts +++ b/src/vr180player/modes/two-d-mode.ts @@ -25,6 +25,7 @@ type TwoDModeOptions = { getCamera: () => any; getCameraControls: () => FallbackCameraControls | undefined; getMaterial: () => any; + getMediaElement: () => HTMLElement | undefined; getRenderer: () => any; getScene: () => any; getVideo: () => HTMLVideoElement | undefined; @@ -43,6 +44,7 @@ export class TwoDMode { private readonly getCamera: () => any; private readonly getCameraControls: () => FallbackCameraControls | undefined; private readonly getMaterial: () => any; + private readonly getMediaElement: () => HTMLElement | undefined; private readonly getRenderer: () => any; private readonly getScene: () => any; private readonly getVideo: () => HTMLVideoElement | undefined; @@ -57,6 +59,7 @@ export class TwoDMode { this.getCamera = options.getCamera; this.getCameraControls = options.getCameraControls; this.getMaterial = options.getMaterial; + this.getMediaElement = options.getMediaElement; this.getRenderer = options.getRenderer; this.getScene = options.getScene; this.getVideo = options.getVideo; @@ -91,11 +94,11 @@ export class TwoDMode { } start(): void { - const video = this.getVideo(); + const mediaElement = this.getMediaElement(); const renderer = this.getRenderer(); const camera = this.getCamera(); - if (!video || !renderer || !camera) { + if (!mediaElement || !renderer || !camera) { console.error("Required components not available for 2D mode"); return; } @@ -104,7 +107,7 @@ export class TwoDMode { this.resizeCanvasFor2D(renderer, camera); const canvas = showFallbackCanvas(renderer); - video.style.display = 'none'; + mediaElement.style.display = 'none'; const mediaTexture = this.callbacks.createMediaTexture(); this.callbacks.positionPlaneForPresentation(this.projectionMode === 'plane'); @@ -139,9 +142,9 @@ export class TwoDMode { this.getCameraControls()?.reset(); this.callbacks.positionPlaneForPresentation(false); - const video = this.getVideo(); - if (video) { - video.style.display = ''; + const mediaElement = this.getMediaElement(); + if (mediaElement) { + mediaElement.style.display = ''; } } diff --git a/src/vr180player/rendering/texture-manager.ts b/src/vr180player/rendering/texture-manager.ts index 0932ed7..0c078e8 100644 --- a/src/vr180player/rendering/texture-manager.ts +++ b/src/vr180player/rendering/texture-manager.ts @@ -8,16 +8,18 @@ export type ManagedMaterial = needsUpdate: boolean; }; -export type TextureFactory = (video: HTMLVideoElement) => TTexture; +export type TextureFactory = (source: TSource) => TTexture; -export class VideoTextureManager { +export class MediaTextureManager { private texture: TTexture | null = null; - private readonly createTexture: TextureFactory; - private readonly video: HTMLVideoElement; + private readonly createTexture: TextureFactory; + private readonly shouldUpdateTexture: () => boolean; + private readonly source: TSource; - constructor(video: HTMLVideoElement, createTexture: TextureFactory) { + constructor(source: TSource, createTexture: TextureFactory, shouldUpdateTexture: () => boolean) { this.createTexture = createTexture; - this.video = video; + this.shouldUpdateTexture = shouldUpdateTexture; + this.source = source; } get current(): TTexture | null { @@ -48,7 +50,7 @@ export class VideoTextureManager | undefined; let vrPanel: VrControlPanel | undefined; let twoDMode: TwoDMode | undefined; const vrPanelVisibility = new VrPanelVisibility(); @@ -59,7 +61,8 @@ let fallbackCameraControls: FallbackCameraControls | undefined; bootstrapPlayer(_playerBase, (context) => { playerContainer = context.playerContainer; projectionMode = context.projectionMode; - videoElement = context.videoElement; + mediaAdapter = context.mediaAdapter; + video = mediaAdapter.element; playBtn = context.playButton; init(); }); @@ -106,18 +109,16 @@ function closeActiveXrSessionAfterContextLoss() { } function restoreVideoTextureAfterContextRestored() { - if (video && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) { + if (mediaAdapter && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) { textureManager?.assignToMaterial(sphereMaterial); updateVRPlayPauseButtonIcon(); updateVRVolumeButtonIcon(); - console.log("Re-initialized video texture after context restoration during VR."); + console.log("Re-initialized media texture after context restoration during VR."); } } -function getVideoTitle() { - return videoElement.getAttribute('title') || - videoElement.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || - "Video Title"; +function getMediaTitle() { + return mediaAdapter?.getTitle() || 'Video Title'; } @@ -131,9 +132,16 @@ function init() { scene = playerRenderer.scene; camera = playerRenderer.camera; renderer = playerRenderer.renderer; + if (!mediaAdapter) { + throw new Error('Media adapter is not initialized.'); + } - video = videoElement; - textureManager = new VideoTextureManager(video, createVideoTextureCore); + video = mediaAdapter.element; + textureManager = new MediaTextureManager( + mediaAdapter.textureSource, + createVideoTextureCore, + () => mediaAdapter?.shouldUpdateTexture() ?? false + ); mediaController = new MediaController({ is2DModeActive, on2DPlaybackResume: show2DControlPanel, @@ -171,12 +179,13 @@ function init() { getCamera: () => camera2D, getCameraControls: () => fallbackCameraControls, getMaterial: () => sphereMaterial, + getMediaElement: () => mediaAdapter?.element, getRenderer: () => renderer, getScene: () => scene, getVideo: () => video, playerContainer, projectionMode, - title: getVideoTitle() + title: getMediaTitle() }); } catch (e) { console.error("INIT_ERROR (Phase 1 - Core Setup):", e); @@ -185,7 +194,7 @@ function init() { } try { // Phase 2: VR Control Panel UI - vrPanel = createVrControlPanel(scene, getVideoTitle()); + vrPanel = createVrControlPanel(scene, getMediaTitle()); vrControlPanel = vrPanel.group; vrPanelVisibility.setPanel(vrPanel); uiElements.push(...vrPanel.interactables); @@ -372,8 +381,8 @@ function onSelectStartVR(event) { } async function handleEnterVRButtonClick() { - if (!video) { - console.error("Video element not found for VR button click."); + if (!mediaAdapter) { + console.error("Media element not found for VR button click."); return; } @@ -418,10 +427,7 @@ async function actualSessionToggle() { xrSession.addEventListener('end', onVRSessionEnd); - // Hide the regular video element when entering VR - if (video) { - video.style.display = 'none'; - } + mediaAdapter?.hideElement(); if (mediaController && video && (video.paused || video.ended)) { try { @@ -435,8 +441,8 @@ async function actualSessionToggle() { positionPlaneForPresentation(false); textureManager?.dispose(); - if (!video) { - throw new Error("Video element not available for creating texture."); + if (!mediaAdapter) { + throw new Error("Media adapter not available for creating texture."); } if (!activeContentMesh || !sphereMaterial) { throw new Error("VR mesh components not ready for texture."); @@ -495,10 +501,7 @@ function onVRSessionEnd(event) { } } - // Show the regular video element when exiting VR - if (video) { - video.style.display = ''; - } + mediaAdapter?.showElement(); mediaController?.pauseIfPlaying(); @@ -558,7 +561,7 @@ function renderXR(timestamp, frame) { } } try { - textureManager?.updateIfPlaying(); + textureManager?.updateIfNeeded(); renderer.render(scene, camera); } catch (error) { const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error)); diff --git a/tests/media-adapter.test.mjs b/tests/media-adapter.test.mjs new file mode 100644 index 0000000..3959e8c --- /dev/null +++ b/tests/media-adapter.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + createMediaAdapter, + VideoMediaAdapter +} from '../vr180player/media/media-adapter.js'; + +function createVideo({ + ended = false, + paused = false, + source = 'https://cdn.example.com/videos/demo-video.mp4', + title = '' +} = {}) { + return { + classList: { + values: [], + add(value) { + this.values.push(value); + } + }, + ended, + loadCount: 0, + paused, + style: { display: '' }, + getAttribute(name) { + return name === 'title' ? title : ''; + }, + load() { + this.loadCount += 1; + }, + querySelector(selector) { + if (selector !== 'source' || !source) { + return null; + } + + return { src: source }; + } + }; +} + +test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => { + const video = createVideo({ title: 'Demo Title' }); + const adapter = new VideoMediaAdapter(video); + + assert.deepEqual(adapter.capabilities, { + audio: true, + dynamicTexture: true, + playback: true, + timeline: true + }); + assert.equal(adapter.element, video); + assert.equal(adapter.textureSource, video); + assert.equal(adapter.getTitle(), 'Demo Title'); + + adapter.hideElement(); + assert.equal(video.style.display, 'none'); + + adapter.showElement(); + assert.equal(video.style.display, ''); + + adapter.load(); + assert.equal(video.loadCount, 1); +}); + +test('VideoMediaAdapter falls back to source filename and tracks texture update state', () => { + const video = createVideo({ source: 'https://cdn.example.com/media/flat-sbs-demo.mp4' }); + const adapter = new VideoMediaAdapter(video); + + assert.equal(adapter.getTitle(), 'flat sbs demo'); + assert.equal(adapter.shouldUpdateTexture(), true); + + video.paused = true; + assert.equal(adapter.shouldUpdateTexture(), false); + + video.paused = false; + video.ended = true; + assert.equal(adapter.shouldUpdateTexture(), false); +}); + +test('createMediaAdapter finds and marks the supported video element', () => { + const video = createVideo(); + const playerContainer = { + querySelector(selector) { + return selector === 'video' ? video : null; + } + }; + + const adapter = createMediaAdapter(playerContainer); + + assert.ok(adapter instanceof VideoMediaAdapter); + assert.equal(adapter.element, video); + assert.deepEqual(video.classList.values, ['vrwp-video']); +}); diff --git a/tests/texture-manager.test.mjs b/tests/texture-manager.test.mjs index c24f0a3..b556969 100644 --- a/tests/texture-manager.test.mjs +++ b/tests/texture-manager.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { VideoTextureManager } from '../vr180player/rendering/texture-manager.js'; +import { MediaTextureManager } from '../vr180player/rendering/texture-manager.js'; function createTexture(name) { return { @@ -17,13 +17,13 @@ function createVideo({ paused = false, ended = false } = {}) { return { ended, paused }; } -test('VideoTextureManager replaces the previous texture when creating a new one', () => { +test('MediaTextureManager replaces the previous texture when creating a new one', () => { const created = []; - const manager = new VideoTextureManager(createVideo(), () => { + const manager = new MediaTextureManager(createVideo(), () => { const texture = createTexture(`texture-${created.length}`); created.push(texture); return texture; - }); + }, () => true); const first = manager.create(); const second = manager.create(); @@ -34,9 +34,9 @@ test('VideoTextureManager replaces the previous texture when creating a new one' assert.equal(created.length, 2); }); -test('VideoTextureManager assigns and clears material maps', () => { +test('MediaTextureManager assigns and clears material maps', () => { const material = { map: null, needsUpdate: false }; - const manager = new VideoTextureManager(createVideo(), () => createTexture('assigned')); + const manager = new MediaTextureManager(createVideo(), () => createTexture('assigned'), () => true); const texture = manager.assignToMaterial(material); @@ -53,21 +53,25 @@ test('VideoTextureManager assigns and clears material maps', () => { assert.equal(manager.current, null); }); -test('VideoTextureManager only marks textures dirty while playback is active', () => { +test('MediaTextureManager only marks textures dirty when the update predicate allows it', () => { const video = createVideo(); - const manager = new VideoTextureManager(video, () => createTexture('playing')); + const manager = new MediaTextureManager( + video, + () => createTexture('playing'), + () => !video.paused && !video.ended + ); const texture = manager.create(); - manager.updateIfPlaying(); + manager.updateIfNeeded(); assert.equal(texture.needsUpdate, true); texture.needsUpdate = false; video.paused = true; - manager.updateIfPlaying(); + manager.updateIfNeeded(); assert.equal(texture.needsUpdate, false); video.paused = false; video.ended = true; - manager.updateIfPlaying(); + manager.updateIfNeeded(); assert.equal(texture.needsUpdate, false); });