export type MediaCapabilities = { audio: boolean; dynamicTexture: boolean; playback: boolean; timeline: boolean; }; type MediaLoadCallbacks = { onError: (event: Event) => void; onReady: () => void; }; export type MediaKind = 'image' | 'video'; export interface MediaAdapter { readonly capabilities: MediaCapabilities; readonly element: TElement; readonly kind: MediaKind; readonly textureSource: TTextureSource; bindLoadState(callbacks: MediaLoadCallbacks): void; getTitle(): string; hideElement(): void; load(): void; shouldUpdateTexture(): boolean; showElement(): void; } export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter; const VIDEO_CAPABILITIES: MediaCapabilities = { audio: true, dynamicTexture: true, playback: true, timeline: true }; const IMAGE_CAPABILITIES: MediaCapabilities = { audio: false, dynamicTexture: false, playback: false, timeline: false }; export class VideoMediaAdapter implements MediaAdapter { readonly capabilities = VIDEO_CAPABILITIES; readonly kind = 'video' as const; constructor(readonly element: HTMLVideoElement) {} get textureSource(): HTMLVideoElement { return this.element; } getTitle(): string { return this.element.getAttribute('title') || this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || 'Video Title'; } bindLoadState({ onError, onReady }: MediaLoadCallbacks): void { if (this.element.readyState >= this.element.HAVE_METADATA) { queueMicrotask(onReady); } this.element.addEventListener('loadedmetadata', onReady); this.element.addEventListener('canplaythrough', onReady); this.element.addEventListener('error', onError); } hideElement(): void { this.element.style.display = 'none'; } load(): void { this.element.load(); } shouldUpdateTexture(): boolean { return !this.element.paused && !this.element.ended; } showElement(): void { this.element.style.display = ''; } } export class ImageMediaAdapter implements MediaAdapter { readonly capabilities = IMAGE_CAPABILITIES; readonly kind = 'image' as const; constructor(readonly element: HTMLImageElement) {} 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 Title'; } bindLoadState({ onError, onReady }: MediaLoadCallbacks): void { if (this.element.complete && this.element.naturalWidth > 0) { queueMicrotask(onReady); } this.element.addEventListener('load', onReady); this.element.addEventListener('error', onError); } hideElement(): void { this.element.style.display = 'none'; } load(): void { // Images begin loading from markup. Kept for parity with video media. } shouldUpdateTexture(): boolean { return false; } showElement(): void { this.element.style.display = ''; } } export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null { const mediaElements = Array.from( playerContainer.querySelectorAll('video,img') ); if (mediaElements.length !== 1) { return null; } const mediaElement = mediaElements[0]; const tagName = mediaElement.tagName.toLowerCase(); mediaElement.classList.add('vrwp-media'); if (tagName === 'video') { mediaElement.classList.add('vrwp-video'); return new VideoMediaAdapter(mediaElement as HTMLVideoElement); } if (tagName === 'img') { mediaElement.classList.add('vrwp-image'); return new ImageMediaAdapter(mediaElement as HTMLImageElement); } return null; } function getFilenameTitle(source: string): string { return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || ''; }