forked from EXT/VR180-Web-Player
This commit is contained in:
14
README.md
14
README.md
@@ -39,7 +39,16 @@ Use an `img` element for a static SBS image:
|
||||
</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`.
|
||||
Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session:
|
||||
|
||||
```html
|
||||
<div data-vr-web-player data-projection="vr180" data-carousel>
|
||||
<img src="first-sbs-image.png" alt="First image" title="First Image" crossorigin="anonymous">
|
||||
<img src="second-sbs-image.png" alt="Second image" title="Second 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 video/image element, if an image carousel does not contain at least two images and no videos, or if `data-projection` is not `vr180` or `plane`.
|
||||
|
||||
## Media format
|
||||
This version supports side-by-side media only:
|
||||
@@ -56,11 +65,12 @@ When the page loads, the media is embedded normally with an entry button over it
|
||||
- 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.
|
||||
- Image carousels replace skip buttons with previous/next image controls, so you can change stills without leaving the immersive session.
|
||||
- Controller pointers and lightweight hand/controller overlays appear after controller interaction, then fade away after a short idle period so they do not distract from viewing.
|
||||
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
|
||||
|
||||
## Demo
|
||||
Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub links to focused pages for flat 3D image, VR180 3D image, flat 3D video, and VR180 3D video. The root `index.html` redirects there for convenience.
|
||||
Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub links to focused pages for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video. The root `index.html` redirects there for convenience.
|
||||
|
||||
For local experimentation, run:
|
||||
|
||||
|
||||
@@ -51,7 +51,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 exactly one supported media element: video or img.`);
|
||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ export type LucideIconName =
|
||||
| 'pause'
|
||||
| 'maximize'
|
||||
| 'arrow-left'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'rotate-ccw'
|
||||
| 'rotate-cw'
|
||||
| 'volume-2'
|
||||
@@ -37,6 +39,12 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
['path', { d: 'm12 19-7-7 7-7' }],
|
||||
['path', { d: 'M19 12H5' }]
|
||||
],
|
||||
'chevron-left': [
|
||||
['path', { d: 'm15 18-6-6 6-6' }]
|
||||
],
|
||||
'chevron-right': [
|
||||
['path', { d: 'm9 18 6-6-6-6' }]
|
||||
],
|
||||
'rotate-ccw': [
|
||||
['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
|
||||
['path', { d: 'M3 3v5h5' }]
|
||||
|
||||
@@ -32,6 +32,8 @@ export class TwoDControlPanel {
|
||||
private playedBar: HTMLElement | null;
|
||||
private progressBar: HTMLElement | null;
|
||||
private totalTimeDisplay: HTMLElement | null;
|
||||
private backButton: HTMLButtonElement | null;
|
||||
private forwardButton: HTMLButtonElement | null;
|
||||
private playButton: HTMLButtonElement | null;
|
||||
private muteButton: HTMLButtonElement | null;
|
||||
private navControls: HTMLElement | null;
|
||||
@@ -50,6 +52,8 @@ export class TwoDControlPanel {
|
||||
this.progressControls = playerContainer.querySelector('.vrwp-progress');
|
||||
this.progressBar = playerContainer.querySelector('.vrwp-bar');
|
||||
this.playedBar = playerContainer.querySelector('.vrwp-played');
|
||||
this.backButton = playerContainer.querySelector('.vrwp-back');
|
||||
this.forwardButton = playerContainer.querySelector('.vrwp-forward');
|
||||
this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
|
||||
this.muteButton = playerContainer.querySelector('.vrwp-mute');
|
||||
this.navControls = playerContainer.querySelector('.vrwp-nav');
|
||||
@@ -156,10 +160,18 @@ export class TwoDControlPanel {
|
||||
this.progressControls.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.playback && this.navControls) {
|
||||
if (!mediaCapabilities.navigation && this.navControls) {
|
||||
this.navControls.hidden = true;
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.playback && this.playButton) {
|
||||
this.playButton.hidden = true;
|
||||
}
|
||||
|
||||
if (mediaCapabilities.carousel) {
|
||||
this.configureCarouselNavigation();
|
||||
}
|
||||
|
||||
if (!mediaCapabilities.audio && this.muteButton) {
|
||||
this.muteButton.hidden = true;
|
||||
}
|
||||
@@ -170,19 +182,21 @@ export class TwoDControlPanel {
|
||||
this.toggleFullscreen();
|
||||
});
|
||||
|
||||
if (mediaCapabilities.playback) {
|
||||
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
|
||||
if (mediaCapabilities.navigation) {
|
||||
this.backButton?.addEventListener('click', () => {
|
||||
this.callbacks.onRewind();
|
||||
this.show();
|
||||
});
|
||||
|
||||
this.playButton?.addEventListener('click', () => {
|
||||
this.callbacks.onPlayPause();
|
||||
this.forwardButton?.addEventListener('click', () => {
|
||||
this.callbacks.onForward();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
|
||||
this.callbacks.onForward();
|
||||
if (mediaCapabilities.playback) {
|
||||
this.playButton?.addEventListener('click', () => {
|
||||
this.callbacks.onPlayPause();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
@@ -212,6 +226,20 @@ export class TwoDControlPanel {
|
||||
}
|
||||
}
|
||||
|
||||
private configureCarouselNavigation(): void {
|
||||
if (this.backButton) {
|
||||
this.backButton.setAttribute('aria-label', 'Previous image');
|
||||
setLucideIcon(this.backButton, 'chevron-left');
|
||||
this.backButton.querySelector('.vrwp-skip-label')?.remove();
|
||||
}
|
||||
|
||||
if (this.forwardButton) {
|
||||
this.forwardButton.setAttribute('aria-label', 'Next image');
|
||||
setLucideIcon(this.forwardButton, 'chevron-right');
|
||||
this.forwardButton.querySelector('.vrwp-skip-label')?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private toggleFullscreen(): void {
|
||||
if (!document.fullscreenElement) {
|
||||
this.fullscreenTarget.requestFullscreen().catch((err) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type MediaCapabilities = {
|
||||
audio: boolean;
|
||||
carousel: boolean;
|
||||
dynamicTexture: boolean;
|
||||
navigation: boolean;
|
||||
playback: boolean;
|
||||
timeline: boolean;
|
||||
};
|
||||
@@ -21,22 +23,37 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
|
||||
getTitle(): string;
|
||||
hideElement(): void;
|
||||
load(): void;
|
||||
next?(): boolean;
|
||||
previous?(): boolean;
|
||||
shouldUpdateTexture(): boolean;
|
||||
showElement(): void;
|
||||
}
|
||||
|
||||
export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter;
|
||||
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
};
|
||||
|
||||
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
|
||||
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
};
|
||||
@@ -75,6 +92,14 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
|
||||
this.element.load();
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return !this.element.paused && !this.element.ended;
|
||||
}
|
||||
@@ -118,6 +143,14 @@ export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLIma
|
||||
// Images begin loading from markup. Kept for parity with video media.
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -127,10 +160,119 @@ export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLIma
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
||||
readonly kind = 'image' as const;
|
||||
private currentIndex = 0;
|
||||
private isHidden = false;
|
||||
|
||||
constructor(private readonly images: HTMLImageElement[]) {
|
||||
this.images.forEach((image) => {
|
||||
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
||||
});
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
get element(): HTMLImageElement {
|
||||
return this.images[this.currentIndex];
|
||||
}
|
||||
|
||||
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 ${this.currentIndex + 1}`;
|
||||
}
|
||||
|
||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||
let hasReportedReady = false;
|
||||
const reportReadyIfAllLoaded = () => {
|
||||
if (hasReportedReady || !this.areAllImagesReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReportedReady = true;
|
||||
onReady();
|
||||
};
|
||||
|
||||
this.images.forEach((image) => {
|
||||
image.addEventListener('load', reportReadyIfAllLoaded);
|
||||
image.addEventListener('error', onError);
|
||||
});
|
||||
|
||||
if (this.areAllImagesReady()) {
|
||||
queueMicrotask(reportReadyIfAllLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
hideElement(): void {
|
||||
this.isHidden = true;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.images.forEach((image) => {
|
||||
image.loading = 'eager';
|
||||
});
|
||||
}
|
||||
|
||||
next(): boolean {
|
||||
return this.selectRelative(1);
|
||||
}
|
||||
|
||||
previous(): boolean {
|
||||
return this.selectRelative(-1);
|
||||
}
|
||||
|
||||
shouldUpdateTexture(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
showElement(): void {
|
||||
this.isHidden = false;
|
||||
this.applyVisibility();
|
||||
}
|
||||
|
||||
private selectRelative(offset: number): boolean {
|
||||
if (this.images.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
||||
this.applyVisibility();
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyVisibility(): void {
|
||||
this.images.forEach((image, index) => {
|
||||
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
private areAllImagesReady(): boolean {
|
||||
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
|
||||
const mediaElements = Array.from(
|
||||
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
||||
);
|
||||
const videoElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'video');
|
||||
const imageElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'img') as HTMLImageElement[];
|
||||
const isCarousel = isCarouselEnabled(playerContainer);
|
||||
|
||||
if (isCarousel) {
|
||||
if (videoElements.length > 0 || imageElements.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ImageCarouselMediaAdapter(imageElements);
|
||||
}
|
||||
|
||||
if (mediaElements.length !== 1) {
|
||||
return null;
|
||||
@@ -153,6 +295,11 @@ export function createMediaAdapter(playerContainer: HTMLElement): SupportedMedia
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCarouselEnabled(playerContainer: HTMLElement): boolean {
|
||||
const carouselValue = playerContainer.dataset?.carousel;
|
||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function getFilenameTitle(source: string): string {
|
||||
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
|
||||
private texture: TTexture | null = null;
|
||||
private readonly createTexture: TextureFactory<TSource, TTexture>;
|
||||
private readonly shouldUpdateTexture: () => boolean;
|
||||
private readonly source: TSource;
|
||||
private source: TSource;
|
||||
|
||||
constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, shouldUpdateTexture: () => boolean) {
|
||||
this.createTexture = createTexture;
|
||||
@@ -26,6 +26,10 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
|
||||
return this.texture;
|
||||
}
|
||||
|
||||
setSource(source: TSource): void {
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
assignToMaterial(material: ManagedMaterial<TTexture>): TTexture {
|
||||
const texture = this.create();
|
||||
material.map = texture;
|
||||
|
||||
@@ -116,6 +116,39 @@ function createMediaTexture() {
|
||||
return textureManager.create();
|
||||
}
|
||||
|
||||
function refreshMediaTexture() {
|
||||
if (!mediaAdapter || !textureManager || !sphereMaterial) {
|
||||
return;
|
||||
}
|
||||
|
||||
textureManager.setSource(mediaAdapter.textureSource);
|
||||
textureManager.assignToMaterial(sphereMaterial);
|
||||
|
||||
if (renderer?.xr?.isPresenting || twoDMode?.isActive) {
|
||||
mediaAdapter.hideElement();
|
||||
}
|
||||
}
|
||||
|
||||
function navigateForward() {
|
||||
if (mediaAdapter?.next?.()) {
|
||||
refreshMediaTexture();
|
||||
return;
|
||||
}
|
||||
|
||||
mediaController?.forward();
|
||||
updateSeekBarAppearance();
|
||||
}
|
||||
|
||||
function navigateBackward() {
|
||||
if (mediaAdapter?.previous?.()) {
|
||||
refreshMediaTexture();
|
||||
return;
|
||||
}
|
||||
|
||||
mediaController?.rewind();
|
||||
updateSeekBarAppearance();
|
||||
}
|
||||
|
||||
function is2DModeActive() {
|
||||
return twoDMode?.isActive ?? false;
|
||||
}
|
||||
@@ -193,9 +226,9 @@ function init() {
|
||||
twoDMode = new TwoDMode({
|
||||
callbacks: {
|
||||
createMediaTexture,
|
||||
forward: () => mediaController?.forward(),
|
||||
forward: navigateForward,
|
||||
positionPlaneForPresentation,
|
||||
rewind: () => mediaController?.rewind(),
|
||||
rewind: navigateBackward,
|
||||
seekToProgress: (progress) => mediaController?.seekToProgress(progress),
|
||||
showActiveContentMesh,
|
||||
toggleMute: () => mediaController?.toggleMute(),
|
||||
@@ -387,15 +420,13 @@ function onSelectStartVR(event) {
|
||||
if (xrSession) actualSessionToggle();
|
||||
},
|
||||
forward: () => {
|
||||
mediaController?.forward();
|
||||
updateSeekBarAppearance();
|
||||
navigateForward();
|
||||
},
|
||||
hidePanel,
|
||||
isPanelVisible: () => vrPanelVisibility.isVisible,
|
||||
raycaster,
|
||||
rewind: () => {
|
||||
mediaController?.rewind();
|
||||
updateSeekBarAppearance();
|
||||
navigateBackward();
|
||||
},
|
||||
seek: (progress) => {
|
||||
mediaController?.seekToProgress(progress);
|
||||
|
||||
@@ -75,7 +75,9 @@ const VR_BUTTON_ICON_SIZE = 82;
|
||||
|
||||
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
};
|
||||
@@ -156,12 +158,17 @@ export function createVrControlPanel(
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
}
|
||||
|
||||
if (mediaCapabilities.navigation) {
|
||||
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')
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
});
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
@@ -171,7 +178,9 @@ export function createVrControlPanel(
|
||||
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')
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-right', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
<p>SBS image on the VR180 hemisphere.</p>
|
||||
<span class="demo-meta">image / vr180</span>
|
||||
</a>
|
||||
<a class="demo-card" href="./test-3d-image-carousel.html">
|
||||
<h2>3D Image Carousel</h2>
|
||||
<p>Flat SBS image carousel with previous and next controls.</p>
|
||||
<span class="demo-meta">image carousel / plane</span>
|
||||
</a>
|
||||
<a class="demo-card" href="./test-vr180-3d-image-carousel.html">
|
||||
<h2>VR180 Image Carousel</h2>
|
||||
<p>VR180 SBS image carousel in one immersive session.</p>
|
||||
<span class="demo-meta">image carousel / vr180</span>
|
||||
</a>
|
||||
<a class="demo-card" href="./test-3d-video.html">
|
||||
<h2>3D Video</h2>
|
||||
<p>Flat SBS video on a rectangular plane.</p>
|
||||
|
||||
32
test-pages/test-3d-image-carousel.html
Normal file
32
test-pages/test-3d-image-carousel.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>3D Image Carousel Test</title>
|
||||
<link rel="stylesheet" href="./demo.css">
|
||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
||||
</head>
|
||||
<body>
|
||||
<main class="demo-page">
|
||||
<div class="demo-shell demo-player">
|
||||
<header class="demo-topbar">
|
||||
<div>
|
||||
<h1 class="demo-brand">3D Image Carousel</h1>
|
||||
<p class="demo-kicker">Projection: plane. Media: multiple side-by-side images.</p>
|
||||
</div>
|
||||
<a class="demo-back" href="./index.html">Back</a>
|
||||
</header>
|
||||
|
||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||
|
||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane" data-carousel>
|
||||
<img src="../media/169_3d_test.png" alt="Demo SBS image one" title="3D Image Plane 1" crossorigin="anonymous">
|
||||
<img src="../media/169_3d_test.png?slide=2" alt="Demo SBS image two" title="3D Image Plane 2" crossorigin="anonymous">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="./demo-xr-status.js"></script>
|
||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
test-pages/test-vr180-3d-image-carousel.html
Normal file
32
test-pages/test-vr180-3d-image-carousel.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>VR180 3D Image Carousel Test</title>
|
||||
<link rel="stylesheet" href="./demo.css">
|
||||
<link rel="stylesheet" href="../vr180player/vr180-player.css" data-vr-web-player-stylesheet>
|
||||
</head>
|
||||
<body>
|
||||
<main class="demo-page">
|
||||
<div class="demo-shell demo-player">
|
||||
<header class="demo-topbar">
|
||||
<div>
|
||||
<h1 class="demo-brand">VR180 Image Carousel</h1>
|
||||
<p class="demo-kicker">Projection: VR180. Media: multiple side-by-side images.</p>
|
||||
</div>
|
||||
<a class="demo-back" href="./index.html">Back</a>
|
||||
</header>
|
||||
|
||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||
|
||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180" data-carousel>
|
||||
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image one" title="VR180 3D Image 1" crossorigin="anonymous">
|
||||
<img src="../media/VR180_SBS_Test.png?slide=2" alt="Demo VR180 SBS image two" title="VR180 3D Image 2" crossorigin="anonymous">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="./demo-xr-status.js"></script>
|
||||
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createMediaAdapter,
|
||||
ImageCarouselMediaAdapter,
|
||||
ImageMediaAdapter,
|
||||
VideoMediaAdapter
|
||||
} from '../vr180player/media/media-adapter.js';
|
||||
@@ -59,11 +60,20 @@ function createImage({
|
||||
classList: createClassList(),
|
||||
complete,
|
||||
currentSrc: source,
|
||||
listeners: {},
|
||||
naturalWidth,
|
||||
src: source,
|
||||
style: { display: '' },
|
||||
tagName: 'IMG',
|
||||
addEventListener() {},
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] ??= [];
|
||||
this.listeners[type].push(listener);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const listener of this.listeners[type] ?? []) {
|
||||
listener({ currentTarget: this });
|
||||
}
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'title') return title;
|
||||
if (name === 'alt') return alt;
|
||||
@@ -78,7 +88,9 @@ test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () =>
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
});
|
||||
@@ -127,7 +139,9 @@ test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers'
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: false,
|
||||
carousel: false,
|
||||
dynamicTexture: false,
|
||||
navigation: false,
|
||||
playback: false,
|
||||
timeline: false
|
||||
});
|
||||
@@ -151,6 +165,82 @@ test('ImageMediaAdapter falls back to source filename', () => {
|
||||
assert.equal(adapter.getTitle(), 'static sbs demo');
|
||||
});
|
||||
|
||||
test('ImageCarouselMediaAdapter exposes carousel image navigation', () => {
|
||||
const firstImage = createImage({ title: 'First image', source: 'https://cdn.example.com/media/first.png' });
|
||||
const secondImage = createImage({ title: 'Second image', source: 'https://cdn.example.com/media/second.png' });
|
||||
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
|
||||
|
||||
assert.deepEqual(adapter.capabilities, {
|
||||
audio: false,
|
||||
carousel: true,
|
||||
dynamicTexture: false,
|
||||
navigation: true,
|
||||
playback: false,
|
||||
timeline: false
|
||||
});
|
||||
assert.equal(adapter.element, firstImage);
|
||||
assert.equal(adapter.textureSource, firstImage);
|
||||
assert.equal(adapter.getTitle(), 'First image');
|
||||
assert.equal(firstImage.style.display, '');
|
||||
assert.equal(secondImage.style.display, 'none');
|
||||
assert.deepEqual(firstImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
|
||||
assert.deepEqual(secondImage.classList.values, ['vrwp-media', 'vrwp-image', 'vrwp-carousel-image']);
|
||||
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, secondImage);
|
||||
assert.equal(adapter.textureSource, secondImage);
|
||||
assert.equal(adapter.getTitle(), 'Second image');
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, '');
|
||||
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, firstImage);
|
||||
|
||||
adapter.hideElement();
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, 'none');
|
||||
|
||||
adapter.previous();
|
||||
adapter.showElement();
|
||||
assert.equal(adapter.element, secondImage);
|
||||
assert.equal(firstImage.style.display, 'none');
|
||||
assert.equal(secondImage.style.display, '');
|
||||
|
||||
adapter.load();
|
||||
assert.equal(firstImage.loading, 'eager');
|
||||
assert.equal(secondImage.loading, 'eager');
|
||||
});
|
||||
|
||||
test('ImageCarouselMediaAdapter waits for all images before reporting ready', async () => {
|
||||
const firstImage = createImage({ complete: false, naturalWidth: 0 });
|
||||
const secondImage = createImage({ complete: false, naturalWidth: 0 });
|
||||
const adapter = new ImageCarouselMediaAdapter([firstImage, secondImage]);
|
||||
let readyCount = 0;
|
||||
|
||||
adapter.bindLoadState({
|
||||
onError: () => {},
|
||||
onReady: () => {
|
||||
readyCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(readyCount, 0);
|
||||
|
||||
firstImage.complete = true;
|
||||
firstImage.naturalWidth = 1920;
|
||||
firstImage.dispatch('load');
|
||||
assert.equal(readyCount, 0);
|
||||
|
||||
secondImage.complete = true;
|
||||
secondImage.naturalWidth = 1920;
|
||||
secondImage.dispatch('load');
|
||||
assert.equal(readyCount, 1);
|
||||
|
||||
firstImage.dispatch('load');
|
||||
assert.equal(readyCount, 1);
|
||||
});
|
||||
|
||||
test('createMediaAdapter finds and marks the supported video element', () => {
|
||||
const video = createVideo();
|
||||
const playerContainer = {
|
||||
@@ -181,10 +271,32 @@ test('createMediaAdapter finds and marks the supported image element', () => {
|
||||
assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']);
|
||||
});
|
||||
|
||||
test('createMediaAdapter creates an image carousel when requested', () => {
|
||||
const firstImage = createImage({ title: 'First image' });
|
||||
const secondImage = createImage({ title: 'Second image' });
|
||||
const playerContainer = {
|
||||
dataset: { carousel: '' },
|
||||
querySelectorAll(selector) {
|
||||
return selector === 'video,img' ? [firstImage, secondImage] : [];
|
||||
}
|
||||
};
|
||||
|
||||
const adapter = createMediaAdapter(playerContainer);
|
||||
|
||||
assert.ok(adapter instanceof ImageCarouselMediaAdapter);
|
||||
assert.equal(adapter.element, firstImage);
|
||||
assert.equal(adapter.next(), true);
|
||||
assert.equal(adapter.element, secondImage);
|
||||
});
|
||||
|
||||
test('createMediaAdapter refuses missing or ambiguous media elements', () => {
|
||||
const video = createVideo();
|
||||
const image = createImage();
|
||||
const secondImage = createImage();
|
||||
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), null);
|
||||
assert.equal(createMediaAdapter({ querySelectorAll: () => [image, secondImage] }), null);
|
||||
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [image] }), null);
|
||||
assert.equal(createMediaAdapter({ dataset: { carousel: '' }, querySelectorAll: () => [video, image] }), null);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,24 @@ test('MediaTextureManager assigns and clears material maps', () => {
|
||||
assert.equal(manager.current, null);
|
||||
});
|
||||
|
||||
test('MediaTextureManager can switch sources before creating the next texture', () => {
|
||||
const firstSource = { name: 'first' };
|
||||
const secondSource = { name: 'second' };
|
||||
const createdFrom = [];
|
||||
const manager = new MediaTextureManager(firstSource, (source) => {
|
||||
createdFrom.push(source);
|
||||
return createTexture(source.name);
|
||||
}, () => true);
|
||||
|
||||
const firstTexture = manager.create();
|
||||
manager.setSource(secondSource);
|
||||
const secondTexture = manager.create();
|
||||
|
||||
assert.equal(firstTexture.disposed, true);
|
||||
assert.equal(secondTexture.name, 'second');
|
||||
assert.deepEqual(createdFrom, [firstSource, secondSource]);
|
||||
});
|
||||
|
||||
test('MediaTextureManager only marks textures dirty when the update predicate allows it', () => {
|
||||
const video = createVideo();
|
||||
const manager = new MediaTextureManager(
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp [hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-media,
|
||||
.vrwp canvas {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user