1
0

carosel images
All checks were successful
Test / test (push) Successful in 9m32s

This commit is contained in:
Aiden
2026-06-10 15:12:25 +10:00
parent c28386ccdd
commit 857c9ac980
14 changed files with 466 additions and 21 deletions

View File

@@ -39,7 +39,16 @@ Use an `img` element for a static SBS image:
</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 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 ## Media format
This version supports side-by-side media only: 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. - 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. - 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. - 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. - 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. - Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
## Demo ## 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: For local experimentation, run:

View File

@@ -51,7 +51,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 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; return;
} }

View File

@@ -4,6 +4,8 @@ export type LucideIconName =
| 'pause' | 'pause'
| 'maximize' | 'maximize'
| 'arrow-left' | 'arrow-left'
| 'chevron-left'
| 'chevron-right'
| 'rotate-ccw' | 'rotate-ccw'
| 'rotate-cw' | 'rotate-cw'
| 'volume-2' | 'volume-2'
@@ -37,6 +39,12 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
['path', { d: 'm12 19-7-7 7-7' }], ['path', { d: 'm12 19-7-7 7-7' }],
['path', { d: 'M19 12H5' }] ['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': [ '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 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
['path', { d: 'M3 3v5h5' }] ['path', { d: 'M3 3v5h5' }]

View File

@@ -32,6 +32,8 @@ export class TwoDControlPanel {
private playedBar: HTMLElement | null; private playedBar: HTMLElement | null;
private progressBar: HTMLElement | null; private progressBar: HTMLElement | null;
private totalTimeDisplay: HTMLElement | null; private totalTimeDisplay: HTMLElement | null;
private backButton: HTMLButtonElement | null;
private forwardButton: HTMLButtonElement | null;
private playButton: HTMLButtonElement | null; private playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null; private muteButton: HTMLButtonElement | null;
private navControls: HTMLElement | null; private navControls: HTMLElement | null;
@@ -50,6 +52,8 @@ export class TwoDControlPanel {
this.progressControls = playerContainer.querySelector('.vrwp-progress'); 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.backButton = playerContainer.querySelector('.vrwp-back');
this.forwardButton = playerContainer.querySelector('.vrwp-forward');
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'); this.navControls = playerContainer.querySelector('.vrwp-nav');
@@ -156,10 +160,18 @@ export class TwoDControlPanel {
this.progressControls.hidden = true; this.progressControls.hidden = true;
} }
if (!mediaCapabilities.playback && this.navControls) { if (!mediaCapabilities.navigation && this.navControls) {
this.navControls.hidden = true; this.navControls.hidden = true;
} }
if (!mediaCapabilities.playback && this.playButton) {
this.playButton.hidden = true;
}
if (mediaCapabilities.carousel) {
this.configureCarouselNavigation();
}
if (!mediaCapabilities.audio && this.muteButton) { if (!mediaCapabilities.audio && this.muteButton) {
this.muteButton.hidden = true; this.muteButton.hidden = true;
} }
@@ -170,19 +182,21 @@ export class TwoDControlPanel {
this.toggleFullscreen(); this.toggleFullscreen();
}); });
if (mediaCapabilities.playback) { if (mediaCapabilities.navigation) {
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => { this.backButton?.addEventListener('click', () => {
this.callbacks.onRewind(); this.callbacks.onRewind();
this.show(); this.show();
}); });
this.playButton?.addEventListener('click', () => { this.forwardButton?.addEventListener('click', () => {
this.callbacks.onPlayPause(); this.callbacks.onForward();
this.show(); this.show();
}); });
}
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => { if (mediaCapabilities.playback) {
this.callbacks.onForward(); this.playButton?.addEventListener('click', () => {
this.callbacks.onPlayPause();
this.show(); 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 { private toggleFullscreen(): void {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
this.fullscreenTarget.requestFullscreen().catch((err) => { this.fullscreenTarget.requestFullscreen().catch((err) => {

View File

@@ -1,6 +1,8 @@
export type MediaCapabilities = { export type MediaCapabilities = {
audio: boolean; audio: boolean;
carousel: boolean;
dynamicTexture: boolean; dynamicTexture: boolean;
navigation: boolean;
playback: boolean; playback: boolean;
timeline: boolean; timeline: boolean;
}; };
@@ -21,22 +23,37 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
getTitle(): string; getTitle(): string;
hideElement(): void; hideElement(): void;
load(): void; load(): void;
next?(): boolean;
previous?(): boolean;
shouldUpdateTexture(): boolean; shouldUpdateTexture(): boolean;
showElement(): void; showElement(): void;
} }
export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter; export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
const VIDEO_CAPABILITIES: MediaCapabilities = { const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true, audio: true,
carousel: false,
dynamicTexture: true, dynamicTexture: true,
navigation: true,
playback: true, playback: true,
timeline: true timeline: true
}; };
const IMAGE_CAPABILITIES: MediaCapabilities = { const IMAGE_CAPABILITIES: MediaCapabilities = {
audio: false, audio: false,
carousel: false,
dynamicTexture: false, dynamicTexture: false,
navigation: false,
playback: false,
timeline: false
};
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
audio: false,
carousel: true,
dynamicTexture: false,
navigation: true,
playback: false, playback: false,
timeline: false timeline: false
}; };
@@ -75,6 +92,14 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
this.element.load(); this.element.load();
} }
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean { shouldUpdateTexture(): boolean {
return !this.element.paused && !this.element.ended; 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. // Images begin loading from markup. Kept for parity with video media.
} }
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean { shouldUpdateTexture(): boolean {
return false; 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 { export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
const mediaElements = Array.from( const mediaElements = Array.from(
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img') 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) { if (mediaElements.length !== 1) {
return null; return null;
@@ -153,6 +295,11 @@ export function createMediaAdapter(playerContainer: HTMLElement): SupportedMedia
return null; return null;
} }
function isCarouselEnabled(playerContainer: HTMLElement): boolean {
const carouselValue = playerContainer.dataset?.carousel;
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
}
function getFilenameTitle(source: string): string { function getFilenameTitle(source: string): string {
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || ''; return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
} }

View File

@@ -14,7 +14,7 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
private texture: TTexture | null = null; private texture: TTexture | null = null;
private readonly createTexture: TextureFactory<TSource, TTexture>; private readonly createTexture: TextureFactory<TSource, TTexture>;
private readonly shouldUpdateTexture: () => boolean; private readonly shouldUpdateTexture: () => boolean;
private readonly source: TSource; private source: TSource;
constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, shouldUpdateTexture: () => boolean) { constructor(source: TSource, createTexture: TextureFactory<TSource, TTexture>, shouldUpdateTexture: () => boolean) {
this.createTexture = createTexture; this.createTexture = createTexture;
@@ -26,6 +26,10 @@ export class MediaTextureManager<TSource, TTexture extends ManagedTexture = Mana
return this.texture; return this.texture;
} }
setSource(source: TSource): void {
this.source = source;
}
assignToMaterial(material: ManagedMaterial<TTexture>): TTexture { assignToMaterial(material: ManagedMaterial<TTexture>): TTexture {
const texture = this.create(); const texture = this.create();
material.map = texture; material.map = texture;

View File

@@ -116,6 +116,39 @@ function createMediaTexture() {
return textureManager.create(); 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() { function is2DModeActive() {
return twoDMode?.isActive ?? false; return twoDMode?.isActive ?? false;
} }
@@ -193,9 +226,9 @@ function init() {
twoDMode = new TwoDMode({ twoDMode = new TwoDMode({
callbacks: { callbacks: {
createMediaTexture, createMediaTexture,
forward: () => mediaController?.forward(), forward: navigateForward,
positionPlaneForPresentation, positionPlaneForPresentation,
rewind: () => mediaController?.rewind(), rewind: navigateBackward,
seekToProgress: (progress) => mediaController?.seekToProgress(progress), seekToProgress: (progress) => mediaController?.seekToProgress(progress),
showActiveContentMesh, showActiveContentMesh,
toggleMute: () => mediaController?.toggleMute(), toggleMute: () => mediaController?.toggleMute(),
@@ -387,15 +420,13 @@ function onSelectStartVR(event) {
if (xrSession) actualSessionToggle(); if (xrSession) actualSessionToggle();
}, },
forward: () => { forward: () => {
mediaController?.forward(); navigateForward();
updateSeekBarAppearance();
}, },
hidePanel, hidePanel,
isPanelVisible: () => vrPanelVisibility.isVisible, isPanelVisible: () => vrPanelVisibility.isVisible,
raycaster, raycaster,
rewind: () => { rewind: () => {
mediaController?.rewind(); navigateBackward();
updateSeekBarAppearance();
}, },
seek: (progress) => { seek: (progress) => {
mediaController?.seekToProgress(progress); mediaController?.seekToProgress(progress);

View File

@@ -75,7 +75,9 @@ const VR_BUTTON_ICON_SIZE = 82;
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = { const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
audio: true, audio: true,
carousel: false,
dynamicTexture: true, dynamicTexture: true,
navigation: true,
playback: true, playback: true,
timeline: true timeline: true
}; };
@@ -156,12 +158,17 @@ export function createVrControlPanel(
group.add(playPauseButtonMesh); group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh); interactables.push(playPauseButtonMesh);
}
if (mediaCapabilities.navigation) {
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',
size: FIGMA_REWIND_BUTTON_SIZE_PX, 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); group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh); interactables.push(rewindButtonMesh);
@@ -171,7 +178,9 @@ export function createVrControlPanel(
centerY: FIGMA_FORWARD_BUTTON_Y_PX, centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton', name: 'vrForwardButton',
size: FIGMA_FORWARD_BUTTON_SIZE_PX, 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); group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh); interactables.push(forwardButtonMesh);

View File

@@ -27,6 +27,16 @@
<p>SBS image on the VR180 hemisphere.</p> <p>SBS image on the VR180 hemisphere.</p>
<span class="demo-meta">image / vr180</span> <span class="demo-meta">image / vr180</span>
</a> </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"> <a class="demo-card" href="./test-3d-video.html">
<h2>3D Video</h2> <h2>3D Video</h2>
<p>Flat SBS video on a rectangular plane.</p> <p>Flat SBS video on a rectangular plane.</p>

View 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>

View 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>

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createMediaAdapter, createMediaAdapter,
ImageCarouselMediaAdapter,
ImageMediaAdapter, ImageMediaAdapter,
VideoMediaAdapter VideoMediaAdapter
} from '../vr180player/media/media-adapter.js'; } from '../vr180player/media/media-adapter.js';
@@ -59,11 +60,20 @@ function createImage({
classList: createClassList(), classList: createClassList(),
complete, complete,
currentSrc: source, currentSrc: source,
listeners: {},
naturalWidth, naturalWidth,
src: source, src: source,
style: { display: '' }, style: { display: '' },
tagName: 'IMG', 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) { getAttribute(name) {
if (name === 'title') return title; if (name === 'title') return title;
if (name === 'alt') return alt; if (name === 'alt') return alt;
@@ -78,7 +88,9 @@ test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () =>
assert.deepEqual(adapter.capabilities, { assert.deepEqual(adapter.capabilities, {
audio: true, audio: true,
carousel: false,
dynamicTexture: true, dynamicTexture: true,
navigation: true,
playback: true, playback: true,
timeline: true timeline: true
}); });
@@ -127,7 +139,9 @@ test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers'
assert.deepEqual(adapter.capabilities, { assert.deepEqual(adapter.capabilities, {
audio: false, audio: false,
carousel: false,
dynamicTexture: false, dynamicTexture: false,
navigation: false,
playback: false, playback: false,
timeline: false timeline: false
}); });
@@ -151,6 +165,82 @@ test('ImageMediaAdapter falls back to source filename', () => {
assert.equal(adapter.getTitle(), 'static sbs demo'); 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', () => { test('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo(); const video = createVideo();
const playerContainer = { const playerContainer = {
@@ -181,10 +271,32 @@ test('createMediaAdapter finds and marks the supported image element', () => {
assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']); 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', () => { test('createMediaAdapter refuses missing or ambiguous media elements', () => {
const video = createVideo(); const video = createVideo();
const image = createImage(); const image = createImage();
const secondImage = createImage();
assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null); assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), 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);
}); });

View File

@@ -53,6 +53,24 @@ test('MediaTextureManager assigns and clears material maps', () => {
assert.equal(manager.current, null); 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', () => { test('MediaTextureManager only marks textures dirty when the update predicate allows it', () => {
const video = createVideo(); const video = createVideo();
const manager = new MediaTextureManager( const manager = new MediaTextureManager(

View File

@@ -4,6 +4,10 @@
width: 100%; width: 100%;
} }
.vrwp [hidden] {
display: none !important;
}
.vrwp-media, .vrwp-media,
.vrwp canvas { .vrwp canvas {
width: 100%; width: 100%;