1
0

added image support

This commit is contained in:
Aiden
2026-06-10 12:48:36 +10:00
parent 24a166046e
commit 030a8b724b
13 changed files with 477 additions and 171 deletions

View File

@@ -1,10 +1,10 @@
# VR Web Player # 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: The player supports two projection modes:
- `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers. - `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 ## 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. 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:
</div> </div>
``` ```
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 ```html
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`. <div data-vr-web-player data-projection="plane">
<img src="sbs-image.png" alt="Demo image" title="Demo Image" crossorigin="anonymous">
</div>
```
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 ## 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, `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 video plane. - 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 video so viewers do not see the raw double image. - 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. - Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
## Demo ## Demo

View File

@@ -21,11 +21,9 @@
<body> <body>
<main> <main>
<h1>VR Web Player</h1> <h1>VR Web Player</h1>
<p>This is a web-based player for side-by-side stereoscopic video.</p> <p>This is a web-based player for side-by-side stereoscopic media.</p>
<div data-vr-web-player data-projection="vr180"> <div data-vr-web-player data-projection="plane">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata"> <img src="media/169_3d_test.png" alt="Demo SBS image" title="Demo SBS Image" crossorigin="anonymous">
<source src="sbs-video.mp4" type="video/mp4">
</video>
</div> </div>
</main> </main>
<script type="module" src="./vr180player/vr180-player.js"></script> <script type="module" src="./vr180player/vr180-player.js"></script>

View File

@@ -41,7 +41,7 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
const mediaAdapter = createMediaAdapter(playerContainer); const mediaAdapter = createMediaAdapter(playerContainer);
if (!mediaAdapter) { 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; return;
} }
@@ -49,6 +49,15 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
playerContainer.appendChild(playButton); playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel()); playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true; 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(); mediaAdapter.load();
completeXrSupportCheck(playButton, () => { completeXrSupportCheck(playButton, () => {

View File

@@ -21,7 +21,7 @@ export function createPlayButton(): HTMLButtonElement {
const playButton = document.createElement('button'); const playButton = document.createElement('button');
playButton.type = 'button'; playButton.type = 'button';
playButton.className = 'vrwp-play-button'; playButton.className = 'vrwp-play-button';
playButton.setAttribute('aria-label', 'Play video'); playButton.setAttribute('aria-label', 'Open media');
playButton.appendChild(createLucideIcon('circle-play')); playButton.appendChild(createLucideIcon('circle-play'));
return playButton; return playButton;

View File

@@ -1,5 +1,6 @@
import { setLucideIcon } from './icons.js'; import { setLucideIcon } from './icons.js';
import { formatTime } from '../utils/time.js'; import { formatTime } from '../utils/time.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDControlPanelCallbacks = { type TwoDControlPanelCallbacks = {
onForward: () => void; onForward: () => void;
@@ -13,6 +14,7 @@ type TwoDControlPanelOptions = {
callbacks: TwoDControlPanelCallbacks; callbacks: TwoDControlPanelCallbacks;
fullscreenTarget: HTMLElement; fullscreenTarget: HTMLElement;
getIsActive: () => boolean; getIsActive: () => boolean;
mediaCapabilities: MediaCapabilities;
playerContainer: HTMLElement; playerContainer: HTMLElement;
title: string; title: string;
}; };
@@ -32,8 +34,10 @@ export class TwoDControlPanel {
private totalTimeDisplay: HTMLElement | null; private totalTimeDisplay: HTMLElement | null;
private playButton: HTMLButtonElement | null; private playButton: HTMLButtonElement | null;
private muteButton: 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.callbacks = callbacks;
this.fullscreenTarget = fullscreenTarget; this.fullscreenTarget = fullscreenTarget;
this.getIsActive = getIsActive; this.getIsActive = getIsActive;
@@ -43,10 +47,12 @@ export class TwoDControlPanel {
const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title'); const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title');
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time'); this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time'); this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
this.progressControls = playerContainer.querySelector('.vrwp-progress');
this.progressBar = playerContainer.querySelector('.vrwp-bar'); this.progressBar = playerContainer.querySelector('.vrwp-bar');
this.playedBar = playerContainer.querySelector('.vrwp-played'); this.playedBar = playerContainer.querySelector('.vrwp-played');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle'); this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute'); this.muteButton = playerContainer.querySelector('.vrwp-mute');
this.navControls = playerContainer.querySelector('.vrwp-nav');
if (!this.controlPanel) { if (!this.controlPanel) {
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.'); console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
@@ -57,7 +63,8 @@ export class TwoDControlPanel {
videoTitle.textContent = title; videoTitle.textContent = title;
} }
this.bindControls(playerContainer); this.applyCapabilities(mediaCapabilities);
this.bindControls(playerContainer, mediaCapabilities);
} }
show(): void { show(): void {
@@ -144,11 +151,26 @@ 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', () => { playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
this.toggleFullscreen(); this.toggleFullscreen();
}); });
if (mediaCapabilities.playback) {
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => { playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
this.callbacks.onRewind(); this.callbacks.onRewind();
this.show(); this.show();
@@ -163,12 +185,16 @@ export class TwoDControlPanel {
this.callbacks.onForward(); this.callbacks.onForward();
this.show(); this.show();
}); });
}
if (mediaCapabilities.audio) {
this.muteButton?.addEventListener('click', () => { this.muteButton?.addEventListener('click', () => {
this.callbacks.onMute(); this.callbacks.onMute();
this.show(); this.show();
}); });
}
if (mediaCapabilities.timeline) {
this.progressBar?.addEventListener('click', (event) => { this.progressBar?.addEventListener('click', (event) => {
const rect = this.progressBar?.getBoundingClientRect(); const rect = this.progressBar?.getBoundingClientRect();
if (rect && rect.width > 0) { if (rect && rect.width > 0) {
@@ -177,6 +203,7 @@ export class TwoDControlPanel {
this.show(); this.show();
}); });
} }
}
private clearHideTimeout(): void { private clearHideTimeout(): void {
if (this.hideTimeout !== undefined) { if (this.hideTimeout !== undefined) {

View File

@@ -5,11 +5,19 @@ export type MediaCapabilities = {
timeline: boolean; timeline: boolean;
}; };
type MediaLoadCallbacks = {
onError: (event: Event) => void;
onReady: () => void;
};
export type MediaKind = 'image' | 'video';
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> { export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
readonly capabilities: MediaCapabilities; readonly capabilities: MediaCapabilities;
readonly element: TElement; readonly element: TElement;
readonly kind: string; readonly kind: MediaKind;
readonly textureSource: TTextureSource; readonly textureSource: TTextureSource;
bindLoadState(callbacks: MediaLoadCallbacks): void;
getTitle(): string; getTitle(): string;
hideElement(): void; hideElement(): void;
load(): void; load(): void;
@@ -17,7 +25,7 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
showElement(): void; showElement(): void;
} }
export type SupportedMediaAdapter = VideoMediaAdapter; export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter;
const VIDEO_CAPABILITIES: MediaCapabilities = { const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true, audio: true,
@@ -26,9 +34,16 @@ const VIDEO_CAPABILITIES: MediaCapabilities = {
timeline: true timeline: true
}; };
const IMAGE_CAPABILITIES: MediaCapabilities = {
audio: false,
dynamicTexture: false,
playback: false,
timeline: false
};
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> { export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
readonly capabilities = VIDEO_CAPABILITIES; readonly capabilities = VIDEO_CAPABILITIES;
readonly kind = 'video'; readonly kind = 'video' as const;
constructor(readonly element: HTMLVideoElement) {} constructor(readonly element: HTMLVideoElement) {}
@@ -42,6 +57,16 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
'Video Title'; 'Video Title';
} }
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
if (this.element.readyState >= this.element.HAVE_METADATA) {
queueMicrotask(onReady);
}
this.element.addEventListener('loadedmetadata', onReady);
this.element.addEventListener('canplaythrough', onReady);
this.element.addEventListener('error', onError);
}
hideElement(): void { hideElement(): void {
this.element.style.display = 'none'; this.element.style.display = 'none';
} }
@@ -59,12 +84,75 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
} }
} }
export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
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 { export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
const videoElement = playerContainer.querySelector<HTMLVideoElement>('video'); const mediaElements = Array.from(
if (!videoElement) { playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
);
if (mediaElements.length !== 1) {
return null; return null;
} }
videoElement.classList.add('vrwp-video'); const mediaElement = mediaElements[0];
return new VideoMediaAdapter(videoElement); 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, ' ') || '';
} }

View File

@@ -6,6 +6,7 @@ import {
showFallbackCanvas showFallbackCanvas
} from '../rendering/renderer-lifecycle.js'; } from '../rendering/renderer-lifecycle.js';
import { TwoDControlPanel } from '../dom/two-d-control-panel.js'; import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = { type TwoDModeCallbacks = {
createMediaTexture: () => any; createMediaTexture: () => any;
@@ -21,6 +22,7 @@ type TwoDModeCallbacks = {
type TwoDModeOptions = { type TwoDModeOptions = {
callbacks: TwoDModeCallbacks; callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement; fullscreenTarget: HTMLElement;
mediaCapabilities: MediaCapabilities;
getActiveContentMesh: () => any; getActiveContentMesh: () => any;
getCamera: () => any; getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined; getCameraControls: () => FallbackCameraControls | undefined;
@@ -40,6 +42,7 @@ export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks; private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel; private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement; private readonly fullscreenTarget: HTMLElement;
private readonly mediaCapabilities: MediaCapabilities;
private readonly getActiveContentMesh: () => any; private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any; private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined; private readonly getCameraControls: () => FallbackCameraControls | undefined;
@@ -55,6 +58,7 @@ export class TwoDMode {
constructor(options: TwoDModeOptions) { constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks; this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget; this.fullscreenTarget = options.fullscreenTarget;
this.mediaCapabilities = options.mediaCapabilities;
this.getActiveContentMesh = options.getActiveContentMesh; this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera; this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls; this.getCameraControls = options.getCameraControls;
@@ -82,6 +86,7 @@ export class TwoDMode {
this.callbacks.seekToProgress(progress); this.callbacks.seekToProgress(progress);
} }
}, },
mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget, fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active, getIsActive: () => this.active,
playerContainer: this.playerContainer, playerContainer: this.playerContainer,
@@ -120,7 +125,9 @@ export class TwoDMode {
this.callbacks.showActiveContentMesh(); this.callbacks.showActiveContentMesh();
} }
if (this.mediaCapabilities.playback) {
this.callbacks.togglePlayPause(); this.callbacks.togglePlayPause();
}
this.addEventListeners(canvas); this.addEventListeners(canvas);
this.controls.show(); this.controls.show();
this.positionControls(); this.positionControls();
@@ -172,6 +179,7 @@ export class TwoDMode {
updateTimeline(): void { updateTimeline(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.timeline) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -181,6 +189,7 @@ export class TwoDMode {
updatePlaybackButton(): void { updatePlaybackButton(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -190,6 +199,7 @@ export class TwoDMode {
updateMuteButton(): void { updateMuteButton(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.audio) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -199,6 +209,7 @@ export class TwoDMode {
handleVideoEnd(): void { handleVideoEnd(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
this.controls.showPersistent(); this.controls.showPersistent();
this.updatePlaybackButton(); this.updatePlaybackButton();

View File

@@ -98,3 +98,20 @@ export function createVideoTexture(video: HTMLVideoElement) {
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
return texture; 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);
}

View File

@@ -1,6 +1,7 @@
declare module 'https://unpkg.com/three/build/three.module.js' { declare module 'https://unpkg.com/three/build/three.module.js' {
export const Matrix4: any; export const Matrix4: any;
export const CanvasTexture: any; export const CanvasTexture: any;
export const Texture: any;
export const VideoTexture: any; export const VideoTexture: any;
export const LinearFilter: any; export const LinearFilter: any;
export const SRGBColorSpace: any; export const SRGBColorSpace: any;

View File

@@ -11,7 +11,7 @@ import {
positionPlaneForPresentation as positionPlaneForPresentationCore, positionPlaneForPresentation as positionPlaneForPresentationCore,
showActiveContentMesh as showActiveContentMeshCore showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js'; } 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 { FallbackCameraControls } from './modes/fallback-camera-controls.js';
import { MediaController } from './media/media-controller.js'; import { MediaController } from './media/media-controller.js';
import { import {
@@ -49,7 +49,7 @@ let frameCounter = 0;
let isXrLoopActive = false; let isXrLoopActive = false;
let vrControlPanel; let vrControlPanel;
let mediaController: MediaController | undefined; let mediaController: MediaController | undefined;
let textureManager: MediaTextureManager<HTMLVideoElement> | undefined; let textureManager: MediaTextureManager<HTMLImageElement | HTMLVideoElement> | undefined;
let vrPanel: VrControlPanel | undefined; let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined; let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility(); const vrPanelVisibility = new VrPanelVisibility();
@@ -62,7 +62,7 @@ bootstrapPlayer(_playerBase, (context) => {
playerContainer = context.playerContainer; playerContainer = context.playerContainer;
projectionMode = context.projectionMode; projectionMode = context.projectionMode;
mediaAdapter = context.mediaAdapter; mediaAdapter = context.mediaAdapter;
video = mediaAdapter.element; video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
playBtn = context.playButton; playBtn = context.playButton;
init(); init();
}); });
@@ -84,9 +84,9 @@ function positionPlaneForPresentation(isFallback2D = false) {
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE); positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
} }
function createVideoTexture() { function createMediaTexture() {
if (!textureManager) { if (!textureManager) {
throw new Error('Video texture manager is not initialized.'); throw new Error('Media texture manager is not initialized.');
} }
return textureManager.create(); return textureManager.create();
} }
@@ -118,7 +118,7 @@ function restoreVideoTextureAfterContextRestored() {
} }
function getMediaTitle() { 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.'); throw new Error('Media adapter is not initialized.');
} }
video = mediaAdapter.element; video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
textureManager = new MediaTextureManager( textureManager = new MediaTextureManager(
mediaAdapter.textureSource, mediaAdapter.textureSource,
createVideoTextureCore, createMediaTextureCore,
() => mediaAdapter?.shouldUpdateTexture() ?? false () => mediaAdapter?.shouldUpdateTexture() ?? false
); );
mediaController = new MediaController({ mediaController = video
? new MediaController({
is2DModeActive, is2DModeActive,
on2DPlaybackResume: show2DControlPanel, on2DPlaybackResume: show2DControlPanel,
playButton: playBtn, playButton: playBtn,
video video
}); })
: undefined;
const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => { const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
applySbsTextureWindow(renderer, activeCamera, material); applySbsTextureWindow(renderer, activeCamera, material);
}); });
@@ -165,7 +167,7 @@ function init() {
}); });
twoDMode = new TwoDMode({ twoDMode = new TwoDMode({
callbacks: { callbacks: {
createMediaTexture: createVideoTexture, createMediaTexture,
forward: () => mediaController?.forward(), forward: () => mediaController?.forward(),
positionPlaneForPresentation, positionPlaneForPresentation,
rewind: () => mediaController?.rewind(), rewind: () => mediaController?.rewind(),
@@ -175,6 +177,7 @@ function init() {
togglePlayPause: () => mediaController?.togglePlayPause() togglePlayPause: () => mediaController?.togglePlayPause()
}, },
fullscreenTarget: playerContainer, fullscreenTarget: playerContainer,
mediaCapabilities: mediaAdapter.capabilities,
getActiveContentMesh: () => activeContentMesh, getActiveContentMesh: () => activeContentMesh,
getCamera: () => camera2D, getCamera: () => camera2D,
getCameraControls: () => fallbackCameraControls, getCameraControls: () => fallbackCameraControls,
@@ -194,7 +197,7 @@ function init() {
} }
try { // Phase 2: VR Control Panel UI try { // Phase 2: VR Control Panel UI
vrPanel = createVrControlPanel(scene, getMediaTitle()); vrPanel = createVrControlPanel(scene, getMediaTitle(), mediaAdapter?.capabilities);
vrControlPanel = vrPanel.group; vrControlPanel = vrPanel.group;
vrPanelVisibility.setPanel(vrPanel); vrPanelVisibility.setPanel(vrPanel);
uiElements.push(...vrPanel.interactables); uiElements.push(...vrPanel.interactables);
@@ -386,8 +389,7 @@ async function handleEnterVRButtonClick() {
return; return;
} }
// Hide the play button after click hideEnterButton();
mediaController?.hidePlayButton();
// Check if VR is supported // Check if VR is supported
if (playBtn.dataset.xrSupported === "true") { if (playBtn.dataset.xrSupported === "true") {
@@ -448,7 +450,7 @@ async function actualSessionToggle() {
throw new Error("VR mesh components not ready for texture."); throw new Error("VR mesh components not ready for texture.");
} }
if (!textureManager) { if (!textureManager) {
throw new Error("Video texture manager is not initialized."); throw new Error("Media texture manager is not initialized.");
} }
textureManager.assignToMaterial(sphereMaterial); textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh(); showActiveContentMesh();
@@ -491,6 +493,15 @@ async function actualSessionToggle() {
} }
} }
function hideEnterButton() {
if (mediaController) {
mediaController.hidePlayButton();
return;
}
playBtn?.classList.add('hidden');
}
function onVRSessionEnd(event) { function onVRSessionEnd(event) {
const endedSession = event.session; const endedSession = event.session;

View File

@@ -1,6 +1,7 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js'; import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon } from '../dom/icons.js'; import { drawLucideIcon } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js'; import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type ButtonLayout = { type ButtonLayout = {
centerX: number; centerX: number;
@@ -12,21 +13,21 @@ type ButtonLayout = {
export type VrControlPanel = { export type VrControlPanel = {
exitButtonMesh: any; exitButtonMesh: any;
forwardButtonMesh: any; forwardButtonMesh?: any;
group: any; group: any;
interactables: any[]; interactables: any[];
playPauseButtonCanvas: HTMLCanvasElement; playPauseButtonCanvas?: HTMLCanvasElement;
playPauseButtonContext: CanvasRenderingContext2D | null; playPauseButtonContext?: CanvasRenderingContext2D | null;
playPauseButtonMesh: any; playPauseButtonMesh?: any;
playPauseButtonTexture: any; playPauseButtonTexture?: any;
rewindButtonMesh: any; rewindButtonMesh?: any;
seekBarHitAreaMesh: any; seekBarHitAreaMesh?: any;
seekBarProgressMesh: any; seekBarProgressMesh?: any;
seekBarTrackMesh: any; seekBarTrackMesh?: any;
volumeButtonCanvas: HTMLCanvasElement; volumeButtonCanvas?: HTMLCanvasElement;
volumeButtonContext: CanvasRenderingContext2D | null; volumeButtonContext?: CanvasRenderingContext2D | null;
volumeButtonMesh: any; volumeButtonMesh?: any;
volumeButtonTexture: any; volumeButtonTexture?: any;
}; };
const FIGMA_PANEL_WIDTH_PX = 450; 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_TEXTURE_SIZE = 128;
const VR_BUTTON_ICON_SIZE = 82; 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(); const group = new THREE.Group();
group.position.set(0, 0.5, -1.8); group.position.set(0, 0.5, -1.8);
group.rotation.x = 0; group.rotation.x = 0;
@@ -83,9 +95,13 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(panelMesh); group.add(panelMesh);
interactables.push(panelMesh); interactables.push(panelMesh);
let seekBarTrackMesh;
let seekBarProgressMesh;
let seekBarHitAreaMesh;
if (mediaCapabilities.timeline) {
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 }); 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 seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial); seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
seekBarTrackMesh.name = 'seekBarTrackVisual'; seekBarTrackMesh.name = 'seekBarTrackVisual';
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET; seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarTrackMesh.position.z = 0.01; seekBarTrackMesh.position.z = 0.01;
@@ -94,7 +110,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 }); 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 seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial); seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
seekBarProgressMesh.name = 'seekBarProgressVisual'; seekBarProgressMesh.name = 'seekBarProgressVisual';
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR; seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2; seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
@@ -108,21 +124,29 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
); );
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 }); const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial); seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
seekBarHitAreaMesh.name = 'seekBarHitArea'; seekBarHitAreaMesh.name = 'seekBarHitArea';
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET; seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarHitAreaMesh.position.z = 0.012; seekBarHitAreaMesh.position.z = 0.012;
seekBarHitAreaMesh.renderOrder = 2; seekBarHitAreaMesh.renderOrder = 2;
group.add(seekBarHitAreaMesh); group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh); interactables.push(seekBarHitAreaMesh);
}
const playPauseButtonCanvas = document.createElement('canvas'); 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.width = VR_BUTTON_TEXTURE_SIZE;
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE; playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d'); playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas); playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
playPauseButtonTexture.minFilter = THREE.LinearFilter; playPauseButtonTexture.minFilter = THREE.LinearFilter;
const playPauseButtonMesh = createButtonMesh({ playPauseButtonMesh = createButtonMesh({
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX, centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX, centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
name: 'vrPlayPauseButton', name: 'vrPlayPauseButton',
@@ -132,7 +156,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(playPauseButtonMesh); group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh); interactables.push(playPauseButtonMesh);
const rewindButtonMesh = createButtonMesh({ rewindButtonMesh = createButtonMesh({
centerX: FIGMA_REWIND_BUTTON_X_PX, centerX: FIGMA_REWIND_BUTTON_X_PX,
centerY: FIGMA_REWIND_BUTTON_Y_PX, centerY: FIGMA_REWIND_BUTTON_Y_PX,
name: 'vrRewindButton', name: 'vrRewindButton',
@@ -142,7 +166,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(rewindButtonMesh); group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh); interactables.push(rewindButtonMesh);
const forwardButtonMesh = createButtonMesh({ forwardButtonMesh = createButtonMesh({
centerX: FIGMA_FORWARD_BUTTON_X_PX, centerX: FIGMA_FORWARD_BUTTON_X_PX,
centerY: FIGMA_FORWARD_BUTTON_Y_PX, centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton', name: 'vrForwardButton',
@@ -151,6 +175,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
}); });
group.add(forwardButtonMesh); group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh); interactables.push(forwardButtonMesh);
}
const exitButtonMesh = createButtonMesh({ const exitButtonMesh = createButtonMesh({
centerX: FIGMA_EXIT_BUTTON_X_PX, centerX: FIGMA_EXIT_BUTTON_X_PX,
@@ -162,13 +187,18 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(exitButtonMesh); group.add(exitButtonMesh);
interactables.push(exitButtonMesh); interactables.push(exitButtonMesh);
const volumeButtonCanvas = document.createElement('canvas'); let volumeButtonCanvas;
let volumeButtonContext;
let volumeButtonTexture;
let volumeButtonMesh;
if (mediaCapabilities.audio) {
volumeButtonCanvas = document.createElement('canvas');
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE; volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE; volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const volumeButtonContext = volumeButtonCanvas.getContext('2d'); volumeButtonContext = volumeButtonCanvas.getContext('2d');
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas); volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
volumeButtonTexture.minFilter = THREE.LinearFilter; volumeButtonTexture.minFilter = THREE.LinearFilter;
const volumeButtonMesh = createButtonMesh({ volumeButtonMesh = createButtonMesh({
centerX: FIGMA_VOLUME_BUTTON_X_PX, centerX: FIGMA_VOLUME_BUTTON_X_PX,
centerY: FIGMA_VOLUME_BUTTON_Y_PX, centerY: FIGMA_VOLUME_BUTTON_Y_PX,
name: 'vrVolumeButton', name: 'vrVolumeButton',
@@ -177,6 +207,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
}); });
group.add(volumeButtonMesh); group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh); interactables.push(volumeButtonMesh);
}
group.visible = false; group.visible = false;

View File

@@ -2,9 +2,19 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createMediaAdapter, createMediaAdapter,
ImageMediaAdapter,
VideoMediaAdapter VideoMediaAdapter
} from '../vr180player/media/media-adapter.js'; } from '../vr180player/media/media-adapter.js';
function createClassList() {
return {
values: [],
add(...values) {
this.values.push(...values);
}
};
}
function createVideo({ function createVideo({
ended = false, ended = false,
paused = false, paused = false,
@@ -12,16 +22,15 @@ function createVideo({
title = '' title = ''
} = {}) { } = {}) {
return { return {
classList: { HAVE_METADATA: 1,
values: [], classList: createClassList(),
add(value) {
this.values.push(value);
}
},
ended, ended,
loadCount: 0, loadCount: 0,
paused, paused,
readyState: 0,
style: { display: '' }, style: { display: '' },
tagName: 'VIDEO',
addEventListener() {},
getAttribute(name) { getAttribute(name) {
return name === 'title' ? title : ''; 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', () => { test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => {
const video = createVideo({ title: 'Demo Title' }); const video = createVideo({ title: 'Demo Title' });
const adapter = new VideoMediaAdapter(video); 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); 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', () => { test('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo(); const video = createVideo();
const playerContainer = { const playerContainer = {
querySelector(selector) { querySelectorAll(selector) {
return selector === 'video' ? video : null; return selector === 'video,img' ? [video] : [];
} }
}; };
@@ -89,5 +163,28 @@ test('createMediaAdapter finds and marks the supported video element', () => {
assert.ok(adapter instanceof VideoMediaAdapter); assert.ok(adapter instanceof VideoMediaAdapter);
assert.equal(adapter.element, video); 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);
}); });

View File

@@ -4,11 +4,13 @@
width: 100%; width: 100%;
} }
.vrwp-video, .vrwp-media,
.vrwp canvas { .vrwp canvas {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
display: block;
object-fit: contain;
} }
.vrwp-play-button { .vrwp-play-button {