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

@@ -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;
}

View File

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

View File

@@ -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) => {

View File

@@ -1,6 +1,8 @@
export type MediaCapabilities = {
audio: boolean;
carousel: boolean;
dynamicTexture: boolean;
navigation: boolean;
playback: boolean;
timeline: boolean;
};
@@ -21,22 +23,37 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
getTitle(): string;
hideElement(): void;
load(): void;
next?(): boolean;
previous?(): boolean;
shouldUpdateTexture(): boolean;
showElement(): void;
}
export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter;
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true,
carousel: false,
dynamicTexture: true,
navigation: true,
playback: true,
timeline: true
};
const IMAGE_CAPABILITIES: MediaCapabilities = {
audio: false,
carousel: false,
dynamicTexture: false,
navigation: false,
playback: false,
timeline: false
};
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
audio: false,
carousel: true,
dynamicTexture: false,
navigation: true,
playback: false,
timeline: false
};
@@ -75,6 +92,14 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
this.element.load();
}
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean {
return !this.element.paused && !this.element.ended;
}
@@ -118,6 +143,14 @@ export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLIma
// Images begin loading from markup. Kept for parity with video media.
}
next(): boolean {
return false;
}
previous(): boolean {
return false;
}
shouldUpdateTexture(): boolean {
return false;
}
@@ -127,10 +160,119 @@ export class ImageMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLIma
}
}
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
readonly kind = 'image' as const;
private currentIndex = 0;
private isHidden = false;
constructor(private readonly images: HTMLImageElement[]) {
this.images.forEach((image) => {
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
});
this.applyVisibility();
}
get element(): HTMLImageElement {
return this.images[this.currentIndex];
}
get textureSource(): HTMLImageElement {
return this.element;
}
getTitle(): string {
return this.element.getAttribute('title') ||
this.element.getAttribute('alt') ||
getFilenameTitle(this.element.currentSrc || this.element.src) ||
`Image ${this.currentIndex + 1}`;
}
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
let hasReportedReady = false;
const reportReadyIfAllLoaded = () => {
if (hasReportedReady || !this.areAllImagesReady()) {
return;
}
hasReportedReady = true;
onReady();
};
this.images.forEach((image) => {
image.addEventListener('load', reportReadyIfAllLoaded);
image.addEventListener('error', onError);
});
if (this.areAllImagesReady()) {
queueMicrotask(reportReadyIfAllLoaded);
}
}
hideElement(): void {
this.isHidden = true;
this.applyVisibility();
}
load(): void {
this.images.forEach((image) => {
image.loading = 'eager';
});
}
next(): boolean {
return this.selectRelative(1);
}
previous(): boolean {
return this.selectRelative(-1);
}
shouldUpdateTexture(): boolean {
return false;
}
showElement(): void {
this.isHidden = false;
this.applyVisibility();
}
private selectRelative(offset: number): boolean {
if (this.images.length <= 1) {
return false;
}
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
this.applyVisibility();
return true;
}
private applyVisibility(): void {
this.images.forEach((image, index) => {
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
});
}
private areAllImagesReady(): boolean {
return this.images.every((image) => image.complete && image.naturalWidth > 0);
}
}
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
const mediaElements = Array.from(
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
);
const videoElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'video');
const imageElements = mediaElements.filter((element) => element.tagName.toLowerCase() === 'img') as HTMLImageElement[];
const isCarousel = isCarouselEnabled(playerContainer);
if (isCarousel) {
if (videoElements.length > 0 || imageElements.length < 2) {
return null;
}
return new ImageCarouselMediaAdapter(imageElements);
}
if (mediaElements.length !== 1) {
return null;
@@ -153,6 +295,11 @@ export function createMediaAdapter(playerContainer: HTMLElement): SupportedMedia
return null;
}
function isCarouselEnabled(playerContainer: HTMLElement): boolean {
const carouselValue = playerContainer.dataset?.carousel;
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
}
function getFilenameTitle(source: string): string {
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
}

View File

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

View File

@@ -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);

View File

@@ -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);