diff --git a/README.md b/README.md index 9bf9f3b..e9209cb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # VR Web Player -A CDN-friendly web player for side-by-side stereoscopic video. +A CDN-friendly web player for side-by-side stereoscopic video and still images. The player supports two projection modes: - `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers. -- `plane`: a flat stereoscopic video plane in WebXR, with a normal flat left-eye fallback on non-XR browsers. +- `plane`: a flat stereoscopic media plane in WebXR, with a normal flat left-eye fallback on non-XR browsers. ## How to use it Build the player first, then host the generated `vr180player/` directory on your CDN and include the module script. The script automatically loads its matching CSS file from the same folder, and it imports its helper modules with relative module paths. @@ -29,17 +29,31 @@ Use `data-projection="plane"` for flat 3D video on a rectangular plane: ``` -Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container has no video, or if `data-projection` is not `vr180` or `plane`. +Use an `img` element for a static SBS image: -## Video format -This version supports 2:1 side-by-side video using H.264 or HEVC in an mp4 file. It does not support over-under, MV-HEVC, APMP, or `.aivu`. +```html +
+ Demo image +
+``` + +Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported media element, or if `data-projection` is not `vr180` or `plane`. + +## Media format +This version supports side-by-side media only: + +- Video: 2:1 side-by-side video using H.264 or HEVC in an mp4 file. +- Image: side-by-side still images in browser-supported image formats such as PNG, JPEG, or WebP. + +It does not support over-under, MV-HEVC, APMP, or `.aivu`. ## How it works -When the page loads, the video is embedded normally with a play button over the poster frame. When the user clicks play, the player checks for `navigator.xr` and `immersive-vr` support. +When the page loads, the media is embedded normally with an entry button over it. When the user clicks the button, the player checks for `navigator.xr` and `immersive-vr` support. -- In WebXR, `vr180` maps the left and right halves of the SBS video onto the matching eyes of a 180 degree sphere. -- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 video plane. -- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image. +- In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. +- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane. +- Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image. +- Static images show only applicable controls; playback, seek, and mute controls are video-only. - Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required. ## Demo diff --git a/index.html b/index.html index e361875..b118586 100644 --- a/index.html +++ b/index.html @@ -21,11 +21,9 @@

VR Web Player

-

This is a web-based player for side-by-side stereoscopic video.

-
- +

This is a web-based player for side-by-side stereoscopic media.

+
+ Demo SBS image
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 {