1
0

more refactors
All checks were successful
Test / test (push) Successful in 9m32s

This commit is contained in:
Aiden
2026-06-10 12:37:48 +10:00
parent d9a5ec9018
commit 24a166046e
9 changed files with 238 additions and 62 deletions

1
.gitignore vendored
View File

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

View File

@@ -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": {

View File

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

View File

@@ -0,0 +1,70 @@
export type MediaCapabilities = {
audio: boolean;
dynamicTexture: boolean;
playback: boolean;
timeline: boolean;
};
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
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<HTMLVideoElement, HTMLVideoElement> {
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<HTMLVideoElement>('video');
if (!videoElement) {
return null;
}
videoElement.classList.add('vrwp-video');
return new VideoMediaAdapter(videoElement);
}

View File

@@ -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 = '';
}
}

View File

@@ -8,16 +8,18 @@ export type ManagedMaterial<TTexture extends ManagedTexture = ManagedTexture> =
needsUpdate: boolean;
};
export type TextureFactory<TTexture extends ManagedTexture = ManagedTexture> = (video: HTMLVideoElement) => TTexture;
export type TextureFactory<TSource, TTexture extends ManagedTexture = ManagedTexture> = (source: TSource) => TTexture;
export class VideoTextureManager<TTexture extends ManagedTexture = ManagedTexture> {
export class MediaTextureManager<TSource, TTexture extends ManagedTexture = ManagedTexture> {
private texture: TTexture | null = null;
private readonly createTexture: TextureFactory<TTexture>;
private readonly video: HTMLVideoElement;
private readonly createTexture: TextureFactory<TSource, TTexture>;
private readonly shouldUpdateTexture: () => boolean;
private readonly source: TSource;
constructor(video: HTMLVideoElement, createTexture: TextureFactory<TTexture>) {
constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, 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<TTexture extends ManagedTexture = ManagedTextur
create(): TTexture {
this.dispose();
this.texture = this.createTexture(this.video);
this.texture = this.createTexture(this.source);
return this.texture;
}
@@ -59,8 +61,8 @@ export class VideoTextureManager<TTexture extends ManagedTexture = ManagedTextur
}
}
updateIfPlaying(): void {
if (this.texture && !this.video.paused && !this.video.ended) {
updateIfNeeded(): void {
if (this.texture && this.shouldUpdateTexture()) {
this.texture.needsUpdate = true;
}
}

View File

@@ -32,7 +32,8 @@ import {
createPlayerRenderer,
resizePlayerRenderer
} from './rendering/renderer-lifecycle.js';
import { VideoTextureManager } from './rendering/texture-manager.js';
import { MediaTextureManager } from './rendering/texture-manager.js';
import type { SupportedMediaAdapter } from './media/media-adapter.js';
const _playerBase = new URL('.', import.meta.url).href;
@@ -41,13 +42,14 @@ let scene, camera, renderer, video, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
let raycaster, uiElements = [];
let videoElement, playBtn;
let mediaAdapter: SupportedMediaAdapter | undefined;
let playBtn;
let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
let textureManager: VideoTextureManager | undefined;
let textureManager: MediaTextureManager<HTMLVideoElement> | 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));

View File

@@ -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']);
});

View File

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