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>
|
</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:
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }]
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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, ' ') || '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
Reference in New Issue
Block a user