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 @@
+
+
+
+
+
+
+
+
Checking immersive WebXR support...
+
+
+

+

+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
Checking immersive WebXR support...
+
+
+

+

+
+
+
+
+
+
+
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%;