This is a web-based player for side-by-side stereoscopic video.
-
-
-
-
+
This is a web-based player for side-by-side stereoscopic media.
+
+
diff --git a/src/vr180player/bootstrap.ts b/src/vr180player/bootstrap.ts
index 455d9bb..273be9f 100644
--- a/src/vr180player/bootstrap.ts
+++ b/src/vr180player/bootstrap.ts
@@ -41,7 +41,7 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
const mediaAdapter = createMediaAdapter(playerContainer);
if (!mediaAdapter) {
- console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a supported media element (video).`);
+ console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one supported media element: video or img.`);
return;
}
@@ -49,6 +49,15 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true;
+ mediaAdapter.bindLoadState({
+ onError: (event) => {
+ console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
+ playButton.disabled = true;
+ },
+ onReady: () => {
+ playButton.disabled = false;
+ }
+ });
mediaAdapter.load();
completeXrSupportCheck(playButton, () => {
diff --git a/src/vr180player/dom/dom.ts b/src/vr180player/dom/dom.ts
index 798e016..fa8407d 100644
--- a/src/vr180player/dom/dom.ts
+++ b/src/vr180player/dom/dom.ts
@@ -21,7 +21,7 @@ export function createPlayButton(): HTMLButtonElement {
const playButton = document.createElement('button');
playButton.type = 'button';
playButton.className = 'vrwp-play-button';
- playButton.setAttribute('aria-label', 'Play video');
+ playButton.setAttribute('aria-label', 'Open media');
playButton.appendChild(createLucideIcon('circle-play'));
return playButton;
diff --git a/src/vr180player/dom/two-d-control-panel.ts b/src/vr180player/dom/two-d-control-panel.ts
index fdcd424..e9cdfb2 100644
--- a/src/vr180player/dom/two-d-control-panel.ts
+++ b/src/vr180player/dom/two-d-control-panel.ts
@@ -1,5 +1,6 @@
import { setLucideIcon } from './icons.js';
import { formatTime } from '../utils/time.js';
+import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDControlPanelCallbacks = {
onForward: () => void;
@@ -13,6 +14,7 @@ type TwoDControlPanelOptions = {
callbacks: TwoDControlPanelCallbacks;
fullscreenTarget: HTMLElement;
getIsActive: () => boolean;
+ mediaCapabilities: MediaCapabilities;
playerContainer: HTMLElement;
title: string;
};
@@ -32,8 +34,10 @@ export class TwoDControlPanel {
private totalTimeDisplay: HTMLElement | null;
private playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null;
+ private navControls: HTMLElement | null;
+ private progressControls: HTMLElement | null;
- constructor({ callbacks, fullscreenTarget, getIsActive, playerContainer, title }: TwoDControlPanelOptions) {
+ constructor({ callbacks, fullscreenTarget, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
this.callbacks = callbacks;
this.fullscreenTarget = fullscreenTarget;
this.getIsActive = getIsActive;
@@ -43,10 +47,12 @@ export class TwoDControlPanel {
const videoTitle = playerContainer.querySelector
('.vrwp-video-title');
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
+ this.progressControls = playerContainer.querySelector('.vrwp-progress');
this.progressBar = playerContainer.querySelector('.vrwp-bar');
this.playedBar = playerContainer.querySelector('.vrwp-played');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute');
+ this.navControls = playerContainer.querySelector('.vrwp-nav');
if (!this.controlPanel) {
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
@@ -57,7 +63,8 @@ export class TwoDControlPanel {
videoTitle.textContent = title;
}
- this.bindControls(playerContainer);
+ this.applyCapabilities(mediaCapabilities);
+ this.bindControls(playerContainer, mediaCapabilities);
}
show(): void {
@@ -144,38 +151,58 @@ export class TwoDControlPanel {
}
}
- private bindControls(playerContainer: HTMLElement): void {
+ private applyCapabilities(mediaCapabilities: MediaCapabilities): void {
+ if (!mediaCapabilities.timeline && this.progressControls) {
+ this.progressControls.hidden = true;
+ }
+
+ if (!mediaCapabilities.playback && this.navControls) {
+ this.navControls.hidden = true;
+ }
+
+ if (!mediaCapabilities.audio && this.muteButton) {
+ this.muteButton.hidden = true;
+ }
+ }
+
+ private bindControls(playerContainer: HTMLElement, mediaCapabilities: MediaCapabilities): void {
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
this.toggleFullscreen();
});
- playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
- this.callbacks.onRewind();
- this.show();
- });
+ if (mediaCapabilities.playback) {
+ playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
+ this.callbacks.onRewind();
+ this.show();
+ });
- this.playButton?.addEventListener('click', () => {
- this.callbacks.onPlayPause();
- this.show();
- });
+ this.playButton?.addEventListener('click', () => {
+ this.callbacks.onPlayPause();
+ this.show();
+ });
- playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
- this.callbacks.onForward();
- this.show();
- });
+ playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
+ this.callbacks.onForward();
+ this.show();
+ });
+ }
- this.muteButton?.addEventListener('click', () => {
- this.callbacks.onMute();
- this.show();
- });
+ if (mediaCapabilities.audio) {
+ this.muteButton?.addEventListener('click', () => {
+ this.callbacks.onMute();
+ this.show();
+ });
+ }
- this.progressBar?.addEventListener('click', (event) => {
- const rect = this.progressBar?.getBoundingClientRect();
- if (rect && rect.width > 0) {
- this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
- }
- this.show();
- });
+ if (mediaCapabilities.timeline) {
+ this.progressBar?.addEventListener('click', (event) => {
+ const rect = this.progressBar?.getBoundingClientRect();
+ if (rect && rect.width > 0) {
+ this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
+ }
+ this.show();
+ });
+ }
}
private clearHideTimeout(): void {
diff --git a/src/vr180player/media/media-adapter.ts b/src/vr180player/media/media-adapter.ts
index 4972bef..96e6ce0 100644
--- a/src/vr180player/media/media-adapter.ts
+++ b/src/vr180player/media/media-adapter.ts
@@ -5,11 +5,19 @@ export type MediaCapabilities = {
timeline: boolean;
};
+type MediaLoadCallbacks = {
+ onError: (event: Event) => void;
+ onReady: () => void;
+};
+
+export type MediaKind = 'image' | 'video';
+
export interface MediaAdapter {
readonly capabilities: MediaCapabilities;
readonly element: TElement;
- readonly kind: string;
+ readonly kind: MediaKind;
readonly textureSource: TTextureSource;
+ bindLoadState(callbacks: MediaLoadCallbacks): void;
getTitle(): string;
hideElement(): void;
load(): void;
@@ -17,7 +25,7 @@ export interface MediaAdapter {
readonly capabilities = VIDEO_CAPABILITIES;
- readonly kind = 'video';
+ readonly kind = 'video' as const;
constructor(readonly element: HTMLVideoElement) {}
@@ -42,6 +57,16 @@ export class VideoMediaAdapter implements MediaAdapter= this.element.HAVE_METADATA) {
+ queueMicrotask(onReady);
+ }
+
+ this.element.addEventListener('loadedmetadata', onReady);
+ this.element.addEventListener('canplaythrough', onReady);
+ this.element.addEventListener('error', onError);
+ }
+
hideElement(): void {
this.element.style.display = 'none';
}
@@ -59,12 +84,75 @@ export class VideoMediaAdapter implements MediaAdapter {
+ readonly capabilities = IMAGE_CAPABILITIES;
+ readonly kind = 'image' as const;
+
+ constructor(readonly element: HTMLImageElement) {}
+
+ get textureSource(): HTMLImageElement {
+ return this.element;
+ }
+
+ getTitle(): string {
+ return this.element.getAttribute('title') ||
+ this.element.getAttribute('alt') ||
+ getFilenameTitle(this.element.currentSrc || this.element.src) ||
+ 'Image Title';
+ }
+
+ bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
+ if (this.element.complete && this.element.naturalWidth > 0) {
+ queueMicrotask(onReady);
+ }
+
+ this.element.addEventListener('load', onReady);
+ this.element.addEventListener('error', onError);
+ }
+
+ hideElement(): void {
+ this.element.style.display = 'none';
+ }
+
+ load(): void {
+ // Images begin loading from markup. Kept for parity with video media.
+ }
+
+ shouldUpdateTexture(): boolean {
+ return false;
+ }
+
+ showElement(): void {
+ this.element.style.display = '';
+ }
+}
+
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
- const videoElement = playerContainer.querySelector('video');
- if (!videoElement) {
+ const mediaElements = Array.from(
+ playerContainer.querySelectorAll('video,img')
+ );
+
+ if (mediaElements.length !== 1) {
return null;
}
- videoElement.classList.add('vrwp-video');
- return new VideoMediaAdapter(videoElement);
+ const mediaElement = mediaElements[0];
+ const tagName = mediaElement.tagName.toLowerCase();
+ mediaElement.classList.add('vrwp-media');
+
+ if (tagName === 'video') {
+ mediaElement.classList.add('vrwp-video');
+ return new VideoMediaAdapter(mediaElement as HTMLVideoElement);
+ }
+
+ if (tagName === 'img') {
+ mediaElement.classList.add('vrwp-image');
+ return new ImageMediaAdapter(mediaElement as HTMLImageElement);
+ }
+
+ return null;
+}
+
+function getFilenameTitle(source: string): string {
+ return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
}
diff --git a/src/vr180player/modes/two-d-mode.ts b/src/vr180player/modes/two-d-mode.ts
index b62cde6..843d157 100644
--- a/src/vr180player/modes/two-d-mode.ts
+++ b/src/vr180player/modes/two-d-mode.ts
@@ -6,6 +6,7 @@ import {
showFallbackCanvas
} from '../rendering/renderer-lifecycle.js';
import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
+import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = {
createMediaTexture: () => any;
@@ -21,6 +22,7 @@ type TwoDModeCallbacks = {
type TwoDModeOptions = {
callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement;
+ mediaCapabilities: MediaCapabilities;
getActiveContentMesh: () => any;
getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined;
@@ -40,6 +42,7 @@ export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement;
+ private readonly mediaCapabilities: MediaCapabilities;
private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined;
@@ -55,6 +58,7 @@ export class TwoDMode {
constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget;
+ this.mediaCapabilities = options.mediaCapabilities;
this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls;
@@ -82,6 +86,7 @@ export class TwoDMode {
this.callbacks.seekToProgress(progress);
}
},
+ mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active,
playerContainer: this.playerContainer,
@@ -120,7 +125,9 @@ export class TwoDMode {
this.callbacks.showActiveContentMesh();
}
- this.callbacks.togglePlayPause();
+ if (this.mediaCapabilities.playback) {
+ this.callbacks.togglePlayPause();
+ }
this.addEventListeners(canvas);
this.controls.show();
this.positionControls();
@@ -172,6 +179,7 @@ export class TwoDMode {
updateTimeline(): void {
if (!this.active) return;
+ if (!this.mediaCapabilities.timeline) return;
const video = this.getVideo();
if (video) {
@@ -181,6 +189,7 @@ export class TwoDMode {
updatePlaybackButton(): void {
if (!this.active) return;
+ if (!this.mediaCapabilities.playback) return;
const video = this.getVideo();
if (video) {
@@ -190,6 +199,7 @@ export class TwoDMode {
updateMuteButton(): void {
if (!this.active) return;
+ if (!this.mediaCapabilities.audio) return;
const video = this.getVideo();
if (video) {
@@ -199,6 +209,7 @@ export class TwoDMode {
handleVideoEnd(): void {
if (!this.active) return;
+ if (!this.mediaCapabilities.playback) return;
this.controls.showPersistent();
this.updatePlaybackButton();
diff --git a/src/vr180player/rendering/three-utils.ts b/src/vr180player/rendering/three-utils.ts
index 694b3ea..c3d592c 100644
--- a/src/vr180player/rendering/three-utils.ts
+++ b/src/vr180player/rendering/three-utils.ts
@@ -98,3 +98,20 @@ export function createVideoTexture(video: HTMLVideoElement) {
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
+
+export function createImageTexture(image: HTMLImageElement) {
+ const texture = new THREE.Texture(image);
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+export function createMediaTexture(source: HTMLImageElement | HTMLVideoElement) {
+ if (source.tagName.toLowerCase() === 'img') {
+ return createImageTexture(source as HTMLImageElement);
+ }
+
+ return createVideoTexture(source as HTMLVideoElement);
+}
diff --git a/src/vr180player/types.d.ts b/src/vr180player/types.d.ts
index 0e74290..f056218 100644
--- a/src/vr180player/types.d.ts
+++ b/src/vr180player/types.d.ts
@@ -1,6 +1,7 @@
declare module 'https://unpkg.com/three/build/three.module.js' {
export const Matrix4: any;
export const CanvasTexture: any;
+ export const Texture: any;
export const VideoTexture: any;
export const LinearFilter: any;
export const SRGBColorSpace: any;
diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts
index 2878733..1a01e48 100644
--- a/src/vr180player/vr180-player.ts
+++ b/src/vr180player/vr180-player.ts
@@ -11,7 +11,7 @@ import {
positionPlaneForPresentation as positionPlaneForPresentationCore,
showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js';
-import { createVideoTexture as createVideoTextureCore } from './rendering/three-utils.js';
+import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.js';
import { FallbackCameraControls } from './modes/fallback-camera-controls.js';
import { MediaController } from './media/media-controller.js';
import {
@@ -49,7 +49,7 @@ let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
-let textureManager: MediaTextureManager | undefined;
+let textureManager: MediaTextureManager | undefined;
let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility();
@@ -62,7 +62,7 @@ bootstrapPlayer(_playerBase, (context) => {
playerContainer = context.playerContainer;
projectionMode = context.projectionMode;
mediaAdapter = context.mediaAdapter;
- video = mediaAdapter.element;
+ video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
playBtn = context.playButton;
init();
});
@@ -84,9 +84,9 @@ function positionPlaneForPresentation(isFallback2D = false) {
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
}
-function createVideoTexture() {
+function createMediaTexture() {
if (!textureManager) {
- throw new Error('Video texture manager is not initialized.');
+ throw new Error('Media texture manager is not initialized.');
}
return textureManager.create();
}
@@ -118,7 +118,7 @@ function restoreVideoTextureAfterContextRestored() {
}
function getMediaTitle() {
- return mediaAdapter?.getTitle() || 'Video Title';
+ return mediaAdapter?.getTitle() || 'Media Title';
}
@@ -136,18 +136,20 @@ function init() {
throw new Error('Media adapter is not initialized.');
}
- video = mediaAdapter.element;
+ video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
textureManager = new MediaTextureManager(
mediaAdapter.textureSource,
- createVideoTextureCore,
+ createMediaTextureCore,
() => mediaAdapter?.shouldUpdateTexture() ?? false
);
- mediaController = new MediaController({
- is2DModeActive,
- on2DPlaybackResume: show2DControlPanel,
- playButton: playBtn,
- video
- });
+ mediaController = video
+ ? new MediaController({
+ is2DModeActive,
+ on2DPlaybackResume: show2DControlPanel,
+ playButton: playBtn,
+ video
+ })
+ : undefined;
const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
applySbsTextureWindow(renderer, activeCamera, material);
});
@@ -165,7 +167,7 @@ function init() {
});
twoDMode = new TwoDMode({
callbacks: {
- createMediaTexture: createVideoTexture,
+ createMediaTexture,
forward: () => mediaController?.forward(),
positionPlaneForPresentation,
rewind: () => mediaController?.rewind(),
@@ -175,6 +177,7 @@ function init() {
togglePlayPause: () => mediaController?.togglePlayPause()
},
fullscreenTarget: playerContainer,
+ mediaCapabilities: mediaAdapter.capabilities,
getActiveContentMesh: () => activeContentMesh,
getCamera: () => camera2D,
getCameraControls: () => fallbackCameraControls,
@@ -194,7 +197,7 @@ function init() {
}
try { // Phase 2: VR Control Panel UI
- vrPanel = createVrControlPanel(scene, getMediaTitle());
+ vrPanel = createVrControlPanel(scene, getMediaTitle(), mediaAdapter?.capabilities);
vrControlPanel = vrPanel.group;
vrPanelVisibility.setPanel(vrPanel);
uiElements.push(...vrPanel.interactables);
@@ -386,8 +389,7 @@ async function handleEnterVRButtonClick() {
return;
}
- // Hide the play button after click
- mediaController?.hidePlayButton();
+ hideEnterButton();
// Check if VR is supported
if (playBtn.dataset.xrSupported === "true") {
@@ -448,7 +450,7 @@ async function actualSessionToggle() {
throw new Error("VR mesh components not ready for texture.");
}
if (!textureManager) {
- throw new Error("Video texture manager is not initialized.");
+ throw new Error("Media texture manager is not initialized.");
}
textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh();
@@ -491,6 +493,15 @@ async function actualSessionToggle() {
}
}
+function hideEnterButton() {
+ if (mediaController) {
+ mediaController.hidePlayButton();
+ return;
+ }
+
+ playBtn?.classList.add('hidden');
+}
+
function onVRSessionEnd(event) {
const endedSession = event.session;
diff --git a/src/vr180player/xr/vr-control-panel.ts b/src/vr180player/xr/vr-control-panel.ts
index c21d088..ebb654c 100644
--- a/src/vr180player/xr/vr-control-panel.ts
+++ b/src/vr180player/xr/vr-control-panel.ts
@@ -1,6 +1,7 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
+import type { MediaCapabilities } from '../media/media-adapter.js';
type ButtonLayout = {
centerX: number;
@@ -12,21 +13,21 @@ type ButtonLayout = {
export type VrControlPanel = {
exitButtonMesh: any;
- forwardButtonMesh: any;
+ forwardButtonMesh?: any;
group: any;
interactables: any[];
- playPauseButtonCanvas: HTMLCanvasElement;
- playPauseButtonContext: CanvasRenderingContext2D | null;
- playPauseButtonMesh: any;
- playPauseButtonTexture: any;
- rewindButtonMesh: any;
- seekBarHitAreaMesh: any;
- seekBarProgressMesh: any;
- seekBarTrackMesh: any;
- volumeButtonCanvas: HTMLCanvasElement;
- volumeButtonContext: CanvasRenderingContext2D | null;
- volumeButtonMesh: any;
- volumeButtonTexture: any;
+ playPauseButtonCanvas?: HTMLCanvasElement;
+ playPauseButtonContext?: CanvasRenderingContext2D | null;
+ playPauseButtonMesh?: any;
+ playPauseButtonTexture?: any;
+ rewindButtonMesh?: any;
+ seekBarHitAreaMesh?: any;
+ seekBarProgressMesh?: any;
+ seekBarTrackMesh?: any;
+ volumeButtonCanvas?: HTMLCanvasElement;
+ volumeButtonContext?: CanvasRenderingContext2D | null;
+ volumeButtonMesh?: any;
+ volumeButtonTexture?: any;
};
const FIGMA_PANEL_WIDTH_PX = 450;
@@ -72,7 +73,18 @@ const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGH
const VR_BUTTON_TEXTURE_SIZE = 128;
const VR_BUTTON_ICON_SIZE = 82;
-export function createVrControlPanel(scene: any, title: string): VrControlPanel {
+const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
+ audio: true,
+ dynamicTexture: true,
+ playback: true,
+ timeline: true
+};
+
+export function createVrControlPanel(
+ scene: any,
+ title: string,
+ mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
+): VrControlPanel {
const group = new THREE.Group();
group.position.set(0, 0.5, -1.8);
group.rotation.x = 0;
@@ -83,74 +95,87 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(panelMesh);
interactables.push(panelMesh);
- const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
- const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
- const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
- seekBarTrackMesh.name = 'seekBarTrackVisual';
- seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
- seekBarTrackMesh.position.z = 0.01;
- seekBarTrackMesh.renderOrder = 1;
- group.add(seekBarTrackMesh);
+ let seekBarTrackMesh;
+ let seekBarProgressMesh;
+ let seekBarHitAreaMesh;
+ if (mediaCapabilities.timeline) {
+ const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
+ const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
+ seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
+ seekBarTrackMesh.name = 'seekBarTrackVisual';
+ seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
+ seekBarTrackMesh.position.z = 0.01;
+ seekBarTrackMesh.renderOrder = 1;
+ group.add(seekBarTrackMesh);
- const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
- const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
- const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
- seekBarProgressMesh.name = 'seekBarProgressVisual';
- seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
- seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
- seekBarProgressMesh.position.z = 0.015;
- seekBarProgressMesh.scale.x = 0.001;
- seekBarProgressMesh.renderOrder = 2;
- group.add(seekBarProgressMesh);
+ const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
+ const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
+ seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
+ seekBarProgressMesh.name = 'seekBarProgressVisual';
+ seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
+ seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
+ seekBarProgressMesh.position.z = 0.015;
+ seekBarProgressMesh.scale.x = 0.001;
+ seekBarProgressMesh.renderOrder = 2;
+ group.add(seekBarProgressMesh);
- const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
- WORLD_SEEK_BAR_WIDTH,
- WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
- );
- const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
- const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
- seekBarHitAreaMesh.name = 'seekBarHitArea';
- seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
- seekBarHitAreaMesh.position.z = 0.012;
- seekBarHitAreaMesh.renderOrder = 2;
- group.add(seekBarHitAreaMesh);
- interactables.push(seekBarHitAreaMesh);
+ const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
+ WORLD_SEEK_BAR_WIDTH,
+ WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
+ );
+ const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
+ seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
+ seekBarHitAreaMesh.name = 'seekBarHitArea';
+ seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
+ seekBarHitAreaMesh.position.z = 0.012;
+ seekBarHitAreaMesh.renderOrder = 2;
+ group.add(seekBarHitAreaMesh);
+ interactables.push(seekBarHitAreaMesh);
+ }
- const playPauseButtonCanvas = document.createElement('canvas');
- playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
- playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
- const playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
- const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
- playPauseButtonTexture.minFilter = THREE.LinearFilter;
- const playPauseButtonMesh = createButtonMesh({
- centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
- centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
- name: 'vrPlayPauseButton',
- size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
- texture: playPauseButtonTexture
- });
- group.add(playPauseButtonMesh);
- interactables.push(playPauseButtonMesh);
+ let playPauseButtonCanvas;
+ let playPauseButtonContext;
+ let playPauseButtonTexture;
+ let playPauseButtonMesh;
+ let rewindButtonMesh;
+ let forwardButtonMesh;
+ if (mediaCapabilities.playback) {
+ playPauseButtonCanvas = document.createElement('canvas');
+ playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
+ playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
+ playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
+ playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
+ playPauseButtonTexture.minFilter = THREE.LinearFilter;
+ playPauseButtonMesh = createButtonMesh({
+ centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
+ centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
+ name: 'vrPlayPauseButton',
+ size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
+ texture: playPauseButtonTexture
+ });
+ group.add(playPauseButtonMesh);
+ interactables.push(playPauseButtonMesh);
- const rewindButtonMesh = createButtonMesh({
- centerX: FIGMA_REWIND_BUTTON_X_PX,
- centerY: FIGMA_REWIND_BUTTON_Y_PX,
- name: 'vrRewindButton',
- size: FIGMA_REWIND_BUTTON_SIZE_PX,
- texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
- });
- group.add(rewindButtonMesh);
- interactables.push(rewindButtonMesh);
+ rewindButtonMesh = createButtonMesh({
+ centerX: FIGMA_REWIND_BUTTON_X_PX,
+ centerY: FIGMA_REWIND_BUTTON_Y_PX,
+ name: 'vrRewindButton',
+ size: FIGMA_REWIND_BUTTON_SIZE_PX,
+ texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
+ });
+ group.add(rewindButtonMesh);
+ interactables.push(rewindButtonMesh);
- const forwardButtonMesh = createButtonMesh({
- centerX: FIGMA_FORWARD_BUTTON_X_PX,
- centerY: FIGMA_FORWARD_BUTTON_Y_PX,
- name: 'vrForwardButton',
- size: FIGMA_FORWARD_BUTTON_SIZE_PX,
- texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
- });
- group.add(forwardButtonMesh);
- interactables.push(forwardButtonMesh);
+ forwardButtonMesh = createButtonMesh({
+ centerX: FIGMA_FORWARD_BUTTON_X_PX,
+ centerY: FIGMA_FORWARD_BUTTON_Y_PX,
+ name: 'vrForwardButton',
+ size: FIGMA_FORWARD_BUTTON_SIZE_PX,
+ texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
+ });
+ group.add(forwardButtonMesh);
+ interactables.push(forwardButtonMesh);
+ }
const exitButtonMesh = createButtonMesh({
centerX: FIGMA_EXIT_BUTTON_X_PX,
@@ -162,21 +187,27 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(exitButtonMesh);
interactables.push(exitButtonMesh);
- const volumeButtonCanvas = document.createElement('canvas');
- volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
- volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
- const volumeButtonContext = volumeButtonCanvas.getContext('2d');
- const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
- volumeButtonTexture.minFilter = THREE.LinearFilter;
- const volumeButtonMesh = createButtonMesh({
- centerX: FIGMA_VOLUME_BUTTON_X_PX,
- centerY: FIGMA_VOLUME_BUTTON_Y_PX,
- name: 'vrVolumeButton',
- size: FIGMA_VOLUME_BUTTON_SIZE_PX,
- texture: volumeButtonTexture
- });
- group.add(volumeButtonMesh);
- interactables.push(volumeButtonMesh);
+ let volumeButtonCanvas;
+ let volumeButtonContext;
+ let volumeButtonTexture;
+ let volumeButtonMesh;
+ if (mediaCapabilities.audio) {
+ volumeButtonCanvas = document.createElement('canvas');
+ volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
+ volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
+ volumeButtonContext = volumeButtonCanvas.getContext('2d');
+ volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
+ volumeButtonTexture.minFilter = THREE.LinearFilter;
+ volumeButtonMesh = createButtonMesh({
+ centerX: FIGMA_VOLUME_BUTTON_X_PX,
+ centerY: FIGMA_VOLUME_BUTTON_Y_PX,
+ name: 'vrVolumeButton',
+ size: FIGMA_VOLUME_BUTTON_SIZE_PX,
+ texture: volumeButtonTexture
+ });
+ group.add(volumeButtonMesh);
+ interactables.push(volumeButtonMesh);
+ }
group.visible = false;
diff --git a/tests/media-adapter.test.mjs b/tests/media-adapter.test.mjs
index 3959e8c..c09dff2 100644
--- a/tests/media-adapter.test.mjs
+++ b/tests/media-adapter.test.mjs
@@ -2,9 +2,19 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
createMediaAdapter,
+ ImageMediaAdapter,
VideoMediaAdapter
} from '../vr180player/media/media-adapter.js';
+function createClassList() {
+ return {
+ values: [],
+ add(...values) {
+ this.values.push(...values);
+ }
+ };
+}
+
function createVideo({
ended = false,
paused = false,
@@ -12,16 +22,15 @@ function createVideo({
title = ''
} = {}) {
return {
- classList: {
- values: [],
- add(value) {
- this.values.push(value);
- }
- },
+ HAVE_METADATA: 1,
+ classList: createClassList(),
ended,
loadCount: 0,
paused,
+ readyState: 0,
style: { display: '' },
+ tagName: 'VIDEO',
+ addEventListener() {},
getAttribute(name) {
return name === 'title' ? title : '';
},
@@ -38,6 +47,31 @@ function createVideo({
};
}
+function createImage({
+ alt = '',
+ complete = true,
+ naturalWidth = 1920,
+ source = 'https://cdn.example.com/images/demo-image.png',
+ title = ''
+} = {}) {
+ return {
+ alt,
+ classList: createClassList(),
+ complete,
+ currentSrc: source,
+ naturalWidth,
+ src: source,
+ style: { display: '' },
+ tagName: 'IMG',
+ addEventListener() {},
+ getAttribute(name) {
+ if (name === 'title') return title;
+ if (name === 'alt') return alt;
+ return '';
+ }
+ };
+}
+
test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => {
const video = createVideo({ title: 'Demo Title' });
const adapter = new VideoMediaAdapter(video);
@@ -77,11 +111,51 @@ test('VideoMediaAdapter falls back to source filename and tracks texture update
assert.equal(adapter.shouldUpdateTexture(), false);
});
+test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers', async () => {
+ const image = createImage({ alt: 'Alt Title' });
+ const adapter = new ImageMediaAdapter(image);
+ let readyCount = 0;
+
+ adapter.bindLoadState({
+ onError: () => {},
+ onReady: () => {
+ readyCount += 1;
+ }
+ });
+
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.deepEqual(adapter.capabilities, {
+ audio: false,
+ dynamicTexture: false,
+ playback: false,
+ timeline: false
+ });
+ assert.equal(adapter.element, image);
+ assert.equal(adapter.textureSource, image);
+ assert.equal(adapter.getTitle(), 'Alt Title');
+ assert.equal(adapter.shouldUpdateTexture(), false);
+ assert.equal(readyCount, 1);
+
+ adapter.hideElement();
+ assert.equal(image.style.display, 'none');
+
+ adapter.showElement();
+ assert.equal(image.style.display, '');
+});
+
+test('ImageMediaAdapter falls back to source filename', () => {
+ const image = createImage({ alt: '', source: 'https://cdn.example.com/media/static-sbs-demo.png' });
+ const adapter = new ImageMediaAdapter(image);
+
+ assert.equal(adapter.getTitle(), 'static sbs demo');
+});
+
test('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo();
const playerContainer = {
- querySelector(selector) {
- return selector === 'video' ? video : null;
+ querySelectorAll(selector) {
+ return selector === 'video,img' ? [video] : [];
}
};
@@ -89,5 +163,28 @@ test('createMediaAdapter finds and marks the supported video element', () => {
assert.ok(adapter instanceof VideoMediaAdapter);
assert.equal(adapter.element, video);
- assert.deepEqual(video.classList.values, ['vrwp-video']);
+ assert.deepEqual(video.classList.values, ['vrwp-media', 'vrwp-video']);
+});
+
+test('createMediaAdapter finds and marks the supported image element', () => {
+ const image = createImage();
+ const playerContainer = {
+ querySelectorAll(selector) {
+ return selector === 'video,img' ? [image] : [];
+ }
+ };
+
+ const adapter = createMediaAdapter(playerContainer);
+
+ assert.ok(adapter instanceof ImageMediaAdapter);
+ assert.equal(adapter.element, image);
+ assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']);
+});
+
+test('createMediaAdapter refuses missing or ambiguous media elements', () => {
+ const video = createVideo();
+ const image = createImage();
+
+ assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
+ assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), null);
});
diff --git a/vr180player/vr180-player.css b/vr180player/vr180-player.css
index d4cfb7b..0108ff5 100644
--- a/vr180player/vr180-player.css
+++ b/vr180player/vr180-player.css
@@ -4,11 +4,13 @@
width: 100%;
}
-.vrwp-video,
+.vrwp-media,
.vrwp canvas {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
+ display: block;
+ object-fit: contain;
}
.vrwp-play-button {