forked from EXT/VR180-Web-Player
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, ' ') || '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user