From 857c9ac980228995e3ac57344ba96d7a23210133 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:12:25 +1000 Subject: [PATCH] carosel images --- README.md | 14 +- src/vr180player/bootstrap.ts | 2 +- src/vr180player/dom/icons.ts | 8 + src/vr180player/dom/two-d-control-panel.ts | 42 +++++- src/vr180player/media/media-adapter.ts | 149 ++++++++++++++++++- src/vr180player/rendering/texture-manager.ts | 6 +- src/vr180player/vr180-player.ts | 43 +++++- src/vr180player/xr/vr-control-panel.ts | 13 +- test-pages/index.html | 10 ++ test-pages/test-3d-image-carousel.html | 32 ++++ test-pages/test-vr180-3d-image-carousel.html | 32 ++++ tests/media-adapter.test.mjs | 114 +++++++++++++- tests/texture-manager.test.mjs | 18 +++ vr180player/vr180-player.css | 4 + 14 files changed, 466 insertions(+), 21 deletions(-) create mode 100644 test-pages/test-3d-image-carousel.html create mode 100644 test-pages/test-vr180-3d-image-carousel.html diff --git a/README.md b/README.md index 8567f11..91c6218 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,16 @@ Use an `img` element for a static SBS image: ``` -Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported media element, or if `data-projection` is not `vr180` or `plane`. +Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session: + +```html +
+ First image + Second image +
+``` + +Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported 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: diff --git a/src/vr180player/bootstrap.ts b/src/vr180player/bootstrap.ts index c31d2c4..d058b6e 100644 --- a/src/vr180player/bootstrap.ts +++ b/src/vr180player/bootstrap.ts @@ -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; } diff --git a/src/vr180player/dom/icons.ts b/src/vr180player/dom/icons.ts index 8ca7bf0..f54972f 100644 --- a/src/vr180player/dom/icons.ts +++ b/src/vr180player/dom/icons.ts @@ -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 = { ['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' }] diff --git a/src/vr180player/dom/two-d-control-panel.ts b/src/vr180player/dom/two-d-control-panel.ts index e9cdfb2..61f6370 100644 --- a/src/vr180player/dom/two-d-control-panel.ts +++ b/src/vr180player/dom/two-d-control-panel.ts @@ -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) => { diff --git a/src/vr180player/media/media-adapter.ts b/src/vr180player/media/media-adapter.ts index 96e6ce0..48e43ca 100644 --- a/src/vr180player/media/media-adapter.ts +++ b/src/vr180player/media/media-adapter.ts @@ -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 { + 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('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, ' ') || ''; } diff --git a/src/vr180player/rendering/texture-manager.ts b/src/vr180player/rendering/texture-manager.ts index 0c078e8..7c4baaa 100644 --- a/src/vr180player/rendering/texture-manager.ts +++ b/src/vr180player/rendering/texture-manager.ts @@ -14,7 +14,7 @@ export class MediaTextureManager; private readonly shouldUpdateTexture: () => boolean; - private readonly source: TSource; + private source: TSource; constructor(source: TSource, createTexture: TextureFactory, shouldUpdateTexture: () => boolean) { this.createTexture = createTexture; @@ -26,6 +26,10 @@ export class MediaTextureManager): TTexture { const texture = this.create(); material.map = texture; diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index a6976e2..d4faf1c 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -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); diff --git a/src/vr180player/xr/vr-control-panel.ts b/src/vr180player/xr/vr-control-panel.ts index 8686567..128b95e 100644 --- a/src/vr180player/xr/vr-control-panel.ts +++ b/src/vr180player/xr/vr-control-panel.ts @@ -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); diff --git a/test-pages/index.html b/test-pages/index.html index 5a31ca3..b8d861c 100644 --- a/test-pages/index.html +++ b/test-pages/index.html @@ -27,6 +27,16 @@

SBS image on the VR180 hemisphere.

image / vr180 + +

3D Image Carousel

+

Flat SBS image carousel with previous and next controls.

+ image carousel / plane +
+ +

VR180 Image Carousel

+

VR180 SBS image carousel in one immersive session.

+ image carousel / vr180 +

3D Video

Flat SBS video on a rectangular plane.

diff --git a/test-pages/test-3d-image-carousel.html b/test-pages/test-3d-image-carousel.html new file mode 100644 index 0000000..58a5bf7 --- /dev/null +++ b/test-pages/test-3d-image-carousel.html @@ -0,0 +1,32 @@ + + + + + + 3D Image Carousel Test + + + + +
+ +
+ + + + diff --git a/test-pages/test-vr180-3d-image-carousel.html b/test-pages/test-vr180-3d-image-carousel.html new file mode 100644 index 0000000..34ac92b --- /dev/null +++ b/test-pages/test-vr180-3d-image-carousel.html @@ -0,0 +1,32 @@ + + + + + + VR180 3D Image Carousel Test + + + + +
+
+
+
+

VR180 Image Carousel

+

Projection: VR180. Media: multiple side-by-side images.

+
+ Back +
+ +

Checking immersive WebXR support...

+ +
+ Demo VR180 SBS image one + Demo VR180 SBS image two +
+
+
+ + + + diff --git a/tests/media-adapter.test.mjs b/tests/media-adapter.test.mjs index c09dff2..2bed70c 100644 --- a/tests/media-adapter.test.mjs +++ b/tests/media-adapter.test.mjs @@ -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); }); diff --git a/tests/texture-manager.test.mjs b/tests/texture-manager.test.mjs index b556969..7d74b5f 100644 --- a/tests/texture-manager.test.mjs +++ b/tests/texture-manager.test.mjs @@ -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( diff --git a/vr180player/vr180-player.css b/vr180player/vr180-player.css index 0108ff5..771104a 100644 --- a/vr180player/vr180-player.css +++ b/vr180player/vr180-player.css @@ -4,6 +4,10 @@ width: 100%; } +.vrwp [hidden] { + display: none !important; +} + .vrwp-media, .vrwp canvas { width: 100%;