forked from EXT/VR180-Web-Player
@@ -7,8 +7,9 @@ import {
|
||||
VALID_HEAD_LOCKS,
|
||||
VALID_PROJECTIONS
|
||||
} from './config.js';
|
||||
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
|
||||
import { create2DControlPanel, createPlayButton } from './dom/dom.js';
|
||||
import { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
||||
import { applyKnownImmersiveVrSupport } from './xr/xr-support.js';
|
||||
|
||||
export type BootstrapContext = {
|
||||
headLockMode: HeadLockMode;
|
||||
@@ -18,71 +19,61 @@ export type BootstrapContext = {
|
||||
projectionMode: ProjectionMode;
|
||||
};
|
||||
|
||||
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
|
||||
injectPlayerStyles(playerBase);
|
||||
type CreatePlayerContextOptions = {
|
||||
immersiveVrSupported?: boolean;
|
||||
};
|
||||
|
||||
onDocumentReady(() => {
|
||||
const containers = document.querySelectorAll<HTMLElement>(PLAYER_SELECTOR);
|
||||
export function createPlayerContext(playerContainer: HTMLElement, options: CreatePlayerContextOptions = {}): BootstrapContext | null {
|
||||
playerContainer.classList.add('vrwp');
|
||||
|
||||
if (containers.length === 0) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
|
||||
return;
|
||||
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaAdapter = createMediaAdapter(playerContainer);
|
||||
if (!mediaAdapter) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: Internal ${PLAYER_SELECTOR} container must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const playButton = createPlayButton();
|
||||
playerContainer.appendChild(playButton);
|
||||
playerContainer.appendChild(create2DControlPanel());
|
||||
playButton.disabled = true;
|
||||
|
||||
if (options.immersiveVrSupported !== undefined) {
|
||||
applyKnownImmersiveVrSupport(playButton, options.immersiveVrSupported);
|
||||
}
|
||||
|
||||
mediaAdapter.bindLoadState({
|
||||
onError: (event) => {
|
||||
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
||||
playButton.disabled = true;
|
||||
},
|
||||
onReady: () => {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
|
||||
if (containers.length > 1) {
|
||||
console.warn(`VR_WEB_PLAYER_DOM: This version supports exactly one ${PLAYER_SELECTOR} container per page. Found ${containers.length}; no player was initialized.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerContainer = containers[0];
|
||||
playerContainer.classList.add('vrwp');
|
||||
|
||||
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAdapter = createMediaAdapter(playerContainer);
|
||||
if (!mediaAdapter) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const playButton = createPlayButton();
|
||||
playerContainer.appendChild(playButton);
|
||||
playerContainer.appendChild(create2DControlPanel());
|
||||
playButton.disabled = true;
|
||||
mediaAdapter.bindLoadState({
|
||||
onError: (event) => {
|
||||
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
||||
playButton.disabled = true;
|
||||
},
|
||||
onReady: () => {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
});
|
||||
mediaAdapter.load();
|
||||
|
||||
completeXrSupportCheck(playButton, () => {
|
||||
onReady({
|
||||
headLockMode: configuredHeadLock as HeadLockMode,
|
||||
mediaAdapter,
|
||||
playButton,
|
||||
playerContainer,
|
||||
projectionMode: configuredProjection as ProjectionMode
|
||||
});
|
||||
});
|
||||
});
|
||||
mediaAdapter.load();
|
||||
|
||||
return {
|
||||
headLockMode: configuredHeadLock as HeadLockMode,
|
||||
mediaAdapter,
|
||||
playButton,
|
||||
playerContainer,
|
||||
projectionMode: configuredProjection as ProjectionMode
|
||||
};
|
||||
}
|
||||
|
||||
function onDocumentReady(callback: () => void): void {
|
||||
export function onDocumentReady(callback: () => void): void {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||
return;
|
||||
@@ -90,36 +81,3 @@ function onDocumentReady(callback: () => void): void {
|
||||
|
||||
callback();
|
||||
}
|
||||
|
||||
function completeXrSupportCheck(playButton: HTMLButtonElement, onComplete: () => void): void {
|
||||
if (!navigator.xr) {
|
||||
if (!window.isSecureContext) {
|
||||
console.warn('VR_WEB_PLAYER_XR: Immersive WebXR requires a secure context. Serve the page over HTTPS, a trusted tunnel, or a deployed CDN URL to test in a headset.');
|
||||
} else {
|
||||
console.warn('VR_WEB_PLAYER_XR: navigator.xr is not available in this browser.');
|
||||
}
|
||||
markXrUnsupported(playButton);
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
|
||||
if (supported) {
|
||||
playButton.dataset.xrSupported = 'true';
|
||||
} else {
|
||||
console.warn('VR_WEB_PLAYER_XR: immersive-vr is not supported by this browser/device.');
|
||||
markXrUnsupported(playButton);
|
||||
}
|
||||
|
||||
onComplete();
|
||||
}).catch((err) => {
|
||||
console.error('XR Support Check Error:', err);
|
||||
markXrUnsupported(playButton);
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
|
||||
function markXrUnsupported(playButton: HTMLButtonElement): void {
|
||||
playButton.dataset.xrSupported = 'false';
|
||||
playButton.disabled = false;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export const PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||
export const LAUNCHER_SELECTOR = '[data-vr-web-launcher]';
|
||||
|
||||
export type ProjectionMode = 'vr180' | 'plane';
|
||||
export type HeadLockMode = 'auto' | 'position' | 'none';
|
||||
export type LauncherMediaType = 'image' | 'video';
|
||||
|
||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
|
||||
export const VALID_LAUNCHER_MEDIA_TYPES = new Set<LauncherMediaType>(['image', 'video']);
|
||||
|
||||
export const PLANE_WIDTH = 3.2;
|
||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
||||
|
||||
76
src/vr180player/dom/fallback-modal.ts
Normal file
76
src/vr180player/dom/fallback-modal.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createLucideIcon } from './icons.js';
|
||||
|
||||
export class FallbackModal {
|
||||
private readonly content: HTMLElement;
|
||||
private readonly onClose: () => void;
|
||||
private readonly root: HTMLElement;
|
||||
|
||||
constructor(onClose: () => void) {
|
||||
this.onClose = onClose;
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'vrwp-modal';
|
||||
this.root.hidden = true;
|
||||
this.root.setAttribute('role', 'dialog');
|
||||
this.root.setAttribute('aria-modal', 'true');
|
||||
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'vrwp-modal-dialog';
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'vrwp-modal-close';
|
||||
closeButton.setAttribute('aria-label', 'Close fallback player');
|
||||
closeButton.appendChild(createLucideIcon('x'));
|
||||
closeButton.addEventListener('click', () => this.close());
|
||||
|
||||
this.content = document.createElement('div');
|
||||
this.content.className = 'vrwp-modal-content';
|
||||
|
||||
dialog.appendChild(closeButton);
|
||||
dialog.appendChild(this.content);
|
||||
this.root.appendChild(dialog);
|
||||
this.root.addEventListener('click', (event) => {
|
||||
if (event.target === this.root) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isOpen(): boolean {
|
||||
return !this.root.hidden;
|
||||
}
|
||||
|
||||
clearContent(): void {
|
||||
this.content.replaceChildren();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.root.hidden = true;
|
||||
document.removeEventListener('keydown', this.onKeyDown);
|
||||
this.onClose();
|
||||
}
|
||||
|
||||
open(): void {
|
||||
if (!this.root.isConnected) {
|
||||
document.body.appendChild(this.root);
|
||||
}
|
||||
|
||||
this.root.hidden = false;
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
this.root.querySelector<HTMLElement>('.vrwp-modal-close')?.focus();
|
||||
}
|
||||
|
||||
setContent(element: HTMLElement): void {
|
||||
this.content.replaceChildren(element);
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,8 @@ export type LucideIconName =
|
||||
| 'repeat'
|
||||
| 'volume-2'
|
||||
| 'volume-x'
|
||||
| 'log-out';
|
||||
| 'log-out'
|
||||
| 'x';
|
||||
|
||||
type IconAttrs = Record<string, string>;
|
||||
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
||||
@@ -74,6 +75,10 @@ const ICONS: Record<LucideIconName, readonly IconNode[]> = {
|
||||
['path', { d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' }],
|
||||
['polyline', { points: '16 17 21 12 16 7' }],
|
||||
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
|
||||
],
|
||||
x: [
|
||||
['path', { d: 'M18 6 6 18' }],
|
||||
['path', { d: 'm6 6 12 12' }]
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
123
src/vr180player/launcher/launcher-bootstrap.ts
Normal file
123
src/vr180player/launcher/launcher-bootstrap.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { LAUNCHER_SELECTOR } from '../config.js';
|
||||
import { FallbackModal } from '../dom/fallback-modal.js';
|
||||
import { getImmersiveVrSupport } from '../xr/xr-support.js';
|
||||
import {
|
||||
createPlayerContainerFromLauncherConfig,
|
||||
getLauncherAction,
|
||||
readLauncherMediaConfig
|
||||
} from './launcher-config.js';
|
||||
|
||||
export type LauncherPlayerSession = {
|
||||
enterImmersive: () => Promise<boolean>;
|
||||
showFallback: () => void;
|
||||
stopFallback: () => void;
|
||||
};
|
||||
|
||||
type SetupLaunchersOptions = {
|
||||
createSession: (playerContainer: HTMLElement, immersiveVrSupported: boolean) => LauncherPlayerSession | null;
|
||||
};
|
||||
|
||||
type ActiveLauncherSession = {
|
||||
container: HTMLElement;
|
||||
session: LauncherPlayerSession;
|
||||
};
|
||||
|
||||
export function setupLauncherButtons({ createSession }: SetupLaunchersOptions): boolean {
|
||||
const launchers = Array.from(document.querySelectorAll<HTMLElement>(LAUNCHER_SELECTOR));
|
||||
if (launchers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let activeSession: ActiveLauncherSession | undefined;
|
||||
let immersiveVrSupported: boolean | null = null;
|
||||
const hiddenHost = createHiddenLauncherHost();
|
||||
const fallbackModal = new FallbackModal(() => {
|
||||
clearActiveSession();
|
||||
fallbackModal.clearContent();
|
||||
});
|
||||
|
||||
getImmersiveVrSupport().then((supported) => {
|
||||
immersiveVrSupported = supported;
|
||||
for (const launcher of launchers) {
|
||||
launcher.dataset.xrSupported = String(supported);
|
||||
}
|
||||
});
|
||||
|
||||
for (const launcher of launchers) {
|
||||
launcher.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
void handleLauncherClick(launcher);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
async function handleLauncherClick(launcher: HTMLElement): Promise<void> {
|
||||
if (fallbackModal.isOpen) {
|
||||
fallbackModal.close();
|
||||
} else {
|
||||
clearActiveSession();
|
||||
}
|
||||
|
||||
const config = readLauncherMediaConfig(launcher);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldAttemptImmersive = immersiveVrSupported === true || (immersiveVrSupported === null && Boolean(navigator.xr));
|
||||
const action = getLauncherAction(shouldAttemptImmersive);
|
||||
const playerContainer = createPlayerContainerFromLauncherConfig(config);
|
||||
|
||||
if (action === 'immersive') {
|
||||
hiddenHost.appendChild(playerContainer);
|
||||
const session = createSession(playerContainer, true);
|
||||
if (!session) {
|
||||
playerContainer.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession = { container: playerContainer, session };
|
||||
const startedImmersive = await session.enterImmersive();
|
||||
if (!startedImmersive) {
|
||||
fallbackModal.setContent(playerContainer);
|
||||
fallbackModal.open();
|
||||
session.showFallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fallbackModal.setContent(playerContainer);
|
||||
fallbackModal.open();
|
||||
const session = createSession(playerContainer, false);
|
||||
if (!session) {
|
||||
fallbackModal.close();
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession = { container: playerContainer, session };
|
||||
session.showFallback();
|
||||
}
|
||||
|
||||
function clearActiveSession(): void {
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSession.session.stopFallback();
|
||||
activeSession.container.remove();
|
||||
activeSession = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createHiddenLauncherHost(): HTMLElement {
|
||||
const existingHost = document.querySelector<HTMLElement>('.vrwp-launcher-host');
|
||||
if (existingHost) {
|
||||
return existingHost;
|
||||
}
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.className = 'vrwp-launcher-host';
|
||||
host.setAttribute('aria-hidden', 'true');
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
223
src/vr180player/launcher/launcher-config.ts
Normal file
223
src/vr180player/launcher/launcher-config.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
DEFAULT_HEAD_LOCK,
|
||||
DEFAULT_PROJECTION,
|
||||
type HeadLockMode,
|
||||
type LauncherMediaType,
|
||||
type ProjectionMode,
|
||||
VALID_HEAD_LOCKS,
|
||||
VALID_LAUNCHER_MEDIA_TYPES,
|
||||
VALID_PROJECTIONS
|
||||
} from '../config.js';
|
||||
|
||||
export type LauncherAction = 'fallback-modal' | 'immersive';
|
||||
|
||||
export type LauncherMediaConfig = {
|
||||
carousel: boolean;
|
||||
crossOrigin: string;
|
||||
headLockMode: HeadLockMode;
|
||||
mediaType: LauncherMediaType;
|
||||
poster: string;
|
||||
preload: string;
|
||||
projectionMode: ProjectionMode;
|
||||
src: string;
|
||||
srcs: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set(['avif', 'gif', 'jpeg', 'jpg', 'png', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['m4v', 'mov', 'mp4', 'ogv', 'webm']);
|
||||
|
||||
export function getLauncherAction(immersiveVrSupported: boolean): LauncherAction {
|
||||
return immersiveVrSupported ? 'immersive' : 'fallback-modal';
|
||||
}
|
||||
|
||||
export function readLauncherMediaConfig(launcher: HTMLElement): LauncherMediaConfig | null {
|
||||
const srcs = parseSourceList(launcher.dataset.src || '');
|
||||
if (srcs.length === 0) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-src is required on [data-vr-web-launcher].');
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCarousel = isCarouselEnabled(launcher);
|
||||
|
||||
const projectionMode = readProjectionMode(launcher.dataset.projection);
|
||||
if (!projectionMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headLockMode = readHeadLockMode(launcher.dataset.headLock);
|
||||
if (!headLockMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaType = readMediaType(launcher.dataset.mediaType, srcs[0]);
|
||||
if (!mediaType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCarousel && mediaType !== 'image') {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-carousel currently supports image launchers only.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isCarousel && srcs.length < 2) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: data-carousel requires at least two comma-separated data-src values.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isCarousel && srcs.length > 1) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: Multiple data-src values require data-carousel.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
carousel: isCarousel,
|
||||
crossOrigin: (launcher.dataset.crossorigin || '').trim(),
|
||||
headLockMode,
|
||||
mediaType,
|
||||
poster: (launcher.dataset.poster || '').trim(),
|
||||
preload: (launcher.dataset.preload || 'metadata').trim(),
|
||||
projectionMode,
|
||||
src: srcs[0],
|
||||
srcs,
|
||||
title: (launcher.dataset.title || launcher.getAttribute('aria-label') || '').trim(),
|
||||
type: (launcher.dataset.type || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlayerContainerFromLauncherConfig(config: LauncherMediaConfig): HTMLElement {
|
||||
const playerContainer = document.createElement('div');
|
||||
playerContainer.dataset.vrWebPlayer = '';
|
||||
playerContainer.dataset.projection = config.projectionMode;
|
||||
playerContainer.dataset.headLock = config.headLockMode;
|
||||
playerContainer.className = 'vrwp-launcher-player';
|
||||
|
||||
if (config.carousel) {
|
||||
playerContainer.dataset.carousel = '';
|
||||
for (const [index, src] of config.srcs.entries()) {
|
||||
playerContainer.appendChild(createImageElement(config, src, index));
|
||||
}
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
if (config.mediaType === 'video') {
|
||||
playerContainer.appendChild(createVideoElement(config));
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
playerContainer.appendChild(createImageElement(config, config.src, 0));
|
||||
return playerContainer;
|
||||
}
|
||||
|
||||
export function inferLauncherMediaType(src: string): LauncherMediaType | null {
|
||||
const extension = getExtension(src);
|
||||
if (!extension) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (IMAGE_EXTENSIONS.has(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (VIDEO_EXTENSIONS.has(extension)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createVideoElement(config: LauncherMediaConfig): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
video.title = config.title;
|
||||
video.playsInline = true;
|
||||
video.preload = config.preload as HTMLVideoElement['preload'];
|
||||
|
||||
if (config.crossOrigin) {
|
||||
video.crossOrigin = config.crossOrigin;
|
||||
}
|
||||
|
||||
if (config.poster) {
|
||||
video.poster = config.poster;
|
||||
}
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.src = config.src;
|
||||
if (config.type) {
|
||||
source.type = config.type;
|
||||
}
|
||||
video.appendChild(source);
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
function createImageElement(config: LauncherMediaConfig, src: string, index: number): HTMLImageElement {
|
||||
const image = document.createElement('img');
|
||||
image.src = src;
|
||||
image.alt = config.carousel ? `${config.title || 'Image'} ${index + 1}` : config.title;
|
||||
image.title = config.carousel ? `${config.title || 'Image'} ${index + 1}` : config.title;
|
||||
|
||||
if (config.crossOrigin) {
|
||||
image.crossOrigin = config.crossOrigin;
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function isCarouselEnabled(launcher: HTMLElement): boolean {
|
||||
const carouselValue = launcher.dataset.carousel;
|
||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
function parseSourceList(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((src) => src.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function readProjectionMode(value: string | undefined): ProjectionMode | null {
|
||||
const projectionMode = (value || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||
if (!VALID_PROJECTIONS.has(projectionMode as ProjectionMode)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-projection="${projectionMode}". Use "vr180" or "plane".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return projectionMode as ProjectionMode;
|
||||
}
|
||||
|
||||
function readHeadLockMode(value: string | undefined): HeadLockMode | null {
|
||||
const headLockMode = (value || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||
if (!VALID_HEAD_LOCKS.has(headLockMode as HeadLockMode)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-head-lock="${headLockMode}". Use "auto", "position", or "none".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return headLockMode as HeadLockMode;
|
||||
}
|
||||
|
||||
function readMediaType(value: string | undefined, src: string): LauncherMediaType | null {
|
||||
const configuredType = (value || '').trim().toLowerCase();
|
||||
if (configuredType) {
|
||||
if (!VALID_LAUNCHER_MEDIA_TYPES.has(configuredType as LauncherMediaType)) {
|
||||
console.error(`VR_WEB_PLAYER_LAUNCHER: Unsupported data-media-type="${configuredType}". Use "image" or "video".`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return configuredType as LauncherMediaType;
|
||||
}
|
||||
|
||||
const inferredType = inferLauncherMediaType(src);
|
||||
if (!inferredType) {
|
||||
console.error('VR_WEB_PLAYER_LAUNCHER: Could not infer media type from data-src. Add data-media-type="image" or data-media-type="video".');
|
||||
return null;
|
||||
}
|
||||
|
||||
return inferredType;
|
||||
}
|
||||
|
||||
function getExtension(src: string): string {
|
||||
const cleanSrc = src.split(/[?#]/, 1)[0].toLowerCase();
|
||||
const extension = cleanSrc.slice(cleanSrc.lastIndexOf('.') + 1);
|
||||
return extension === cleanSrc ? '' : extension;
|
||||
}
|
||||
@@ -51,6 +51,71 @@
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
|
||||
.vrwp-launcher-host {
|
||||
position: fixed;
|
||||
left: -1px;
|
||||
top: -1px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vrwp-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483647;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(8, 8, 8, 0.82);
|
||||
}
|
||||
|
||||
.vrwp-modal[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vrwp-modal-dialog {
|
||||
position: relative;
|
||||
width: min(1120px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
box-shadow: 0 18px 70px rgba(0, 0, 0, 0.42);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.vrwp-modal-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal .vrwp {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vrwp-modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.58);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrwp-modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.76);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vrwp-play-button {
|
||||
width: 60px;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import {
|
||||
LAUNCHER_SELECTOR,
|
||||
PLANE_2D_DISTANCE,
|
||||
PLANE_DISTANCE,
|
||||
PLAYER_SELECTOR,
|
||||
type HeadLockMode,
|
||||
type ProjectionMode
|
||||
} from './config.js';
|
||||
import { bootstrapPlayer, type BootstrapContext } from './bootstrap.js';
|
||||
import {
|
||||
createPlayerContext,
|
||||
onDocumentReady,
|
||||
type BootstrapContext
|
||||
} from './bootstrap.js';
|
||||
import { injectPlayerStyles } from './dom/dom.js';
|
||||
import { createContentScene } from './rendering/content-scene.js';
|
||||
import {
|
||||
applyHeadPositionLock as applyHeadPositionLockCore,
|
||||
@@ -42,6 +49,7 @@ import {
|
||||
PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||
getVideoAwareAutoHideDelayMs
|
||||
} from './utils/control-panel-timing.js';
|
||||
import { setupLauncherButtons } from './launcher/launcher-bootstrap.js';
|
||||
|
||||
export class PlayerSession {
|
||||
private readonly headLockMode: HeadLockMode;
|
||||
@@ -51,7 +59,11 @@ export class PlayerSession {
|
||||
private readonly projectionMode: ProjectionMode;
|
||||
private readonly uiElements: any[] = [];
|
||||
private readonly vrPanelVisibility = new VrPanelVisibility();
|
||||
private readonly handleEnterButtonClick = () => {
|
||||
void this.enterOrShowFallback();
|
||||
};
|
||||
private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event);
|
||||
private readonly handleWindowResize = () => this.onWindowResize();
|
||||
private readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame);
|
||||
|
||||
private activeContentMesh: any;
|
||||
@@ -172,10 +184,8 @@ export class PlayerSession {
|
||||
}
|
||||
|
||||
try {
|
||||
this.playBtn.addEventListener('click', () => {
|
||||
void this.handleEnterVRButtonClick();
|
||||
});
|
||||
window.addEventListener('resize', () => this.onWindowResize());
|
||||
this.playBtn.addEventListener('click', this.handleEnterButtonClick);
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
|
||||
if (this.video) {
|
||||
bindVideoEvents({
|
||||
@@ -209,6 +219,50 @@ export class PlayerSession {
|
||||
}
|
||||
}
|
||||
|
||||
async enterOrShowFallback(): Promise<void> {
|
||||
if (this.playBtn.dataset.xrSupported === 'true') {
|
||||
await this.enterImmersive();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showFallback();
|
||||
}
|
||||
|
||||
async enterImmersive(): Promise<boolean> {
|
||||
if (!this.mediaAdapter) {
|
||||
console.error('Media element not found for immersive launcher.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.playBtn.dataset.xrSupported !== 'true') {
|
||||
this.showFallback();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.hideEnterButton();
|
||||
return this.actualSessionToggle();
|
||||
}
|
||||
|
||||
showFallback(): void {
|
||||
if (!this.mediaAdapter) {
|
||||
console.error('Media element not found for fallback player.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideEnterButton();
|
||||
this.resetHeadPositionLock();
|
||||
this.twoDMode?.start();
|
||||
}
|
||||
|
||||
stopFallback(): void {
|
||||
if (this.twoDMode?.isActive) {
|
||||
this.twoDMode.stop();
|
||||
this.onWindowResize();
|
||||
}
|
||||
|
||||
this.mediaController?.pauseIfPlaying();
|
||||
}
|
||||
|
||||
private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void {
|
||||
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
|
||||
}
|
||||
@@ -487,27 +541,10 @@ export class PlayerSession {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleEnterVRButtonClick(): Promise<void> {
|
||||
if (!this.mediaAdapter) {
|
||||
console.error('Media element not found for VR button click.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.hideEnterButton();
|
||||
|
||||
if (this.playBtn.dataset.xrSupported === 'true') {
|
||||
await this.actualSessionToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetHeadPositionLock();
|
||||
this.twoDMode?.start();
|
||||
}
|
||||
|
||||
private async actualSessionToggle(): Promise<void> {
|
||||
private async actualSessionToggle(): Promise<boolean> {
|
||||
if (!this.renderer || !this.renderer.isWebGLRenderer) {
|
||||
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.xrSession) {
|
||||
@@ -521,7 +558,7 @@ export class PlayerSession {
|
||||
console.error('Error calling .end() on session:', err);
|
||||
this.onVRSessionEnd({ session: sessionToClose });
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -569,6 +606,7 @@ export class PlayerSession {
|
||||
this.isXrLoopActive = true;
|
||||
this.renderer.setAnimationLoop(this.renderXrFrame);
|
||||
this.frameCounter = 0;
|
||||
return true;
|
||||
} catch (err) {
|
||||
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
|
||||
console.error(sessionStartError, err);
|
||||
@@ -592,6 +630,7 @@ export class PlayerSession {
|
||||
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
|
||||
this.renderer.setAnimationLoop(null);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,7 +743,31 @@ export class PlayerSession {
|
||||
const playerBase = new URL('.', import.meta.url).href;
|
||||
let activeSession: PlayerSession | undefined;
|
||||
|
||||
bootstrapPlayer(playerBase, (context) => {
|
||||
activeSession = new PlayerSession(context);
|
||||
activeSession.init();
|
||||
injectPlayerStyles(playerBase);
|
||||
|
||||
onDocumentReady(() => {
|
||||
const initialized = setupLauncherButtons({
|
||||
createSession: (playerContainer, immersiveVrSupported) => {
|
||||
const context = createPlayerContext(playerContainer, { immersiveVrSupported });
|
||||
if (!context) {
|
||||
return null;
|
||||
}
|
||||
|
||||
activeSession = new PlayerSession(context);
|
||||
activeSession.init();
|
||||
return activeSession;
|
||||
}
|
||||
});
|
||||
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldInlineContainers = document.querySelectorAll(PLAYER_SELECTOR).length;
|
||||
if (oldInlineContainers > 0) {
|
||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} is now internal and is no longer initialized from page markup. Use one or more ${LAUNCHER_SELECTOR} elements instead.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`VR_WEB_PLAYER_DOM: Expected at least one ${LAUNCHER_SELECTOR} element for the gallery launcher.`);
|
||||
});
|
||||
|
||||
53
src/vr180player/xr/xr-support.ts
Normal file
53
src/vr180player/xr/xr-support.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
let immersiveVrSupportPromise: Promise<boolean> | undefined;
|
||||
|
||||
export function getImmersiveVrSupport(): Promise<boolean> {
|
||||
if (!immersiveVrSupportPromise) {
|
||||
immersiveVrSupportPromise = checkImmersiveVrSupport();
|
||||
}
|
||||
|
||||
return immersiveVrSupportPromise;
|
||||
}
|
||||
|
||||
export function applyKnownImmersiveVrSupport(playButton: HTMLButtonElement, supported: boolean): void {
|
||||
playButton.dataset.xrSupported = supported ? 'true' : 'false';
|
||||
|
||||
if (!supported) {
|
||||
playButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyImmersiveVrSupportToButton(playButton: HTMLButtonElement): Promise<boolean> {
|
||||
const supported = await getImmersiveVrSupport();
|
||||
applyKnownImmersiveVrSupport(playButton, supported);
|
||||
|
||||
if (!supported) {
|
||||
logImmersiveVrUnsupported();
|
||||
}
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
function checkImmersiveVrSupport(): Promise<boolean> {
|
||||
if (!navigator.xr) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return navigator.xr.isSessionSupported('immersive-vr').catch((err) => {
|
||||
console.error('XR Support Check Error:', err);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function logImmersiveVrUnsupported(): void {
|
||||
if (!navigator.xr) {
|
||||
if (!window.isSecureContext) {
|
||||
console.warn('VR_WEB_PLAYER_XR: Immersive WebXR requires a secure context. Serve the page over HTTPS, a trusted tunnel, or a deployed CDN URL to test in a headset.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('VR_WEB_PLAYER_XR: navigator.xr is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('VR_WEB_PLAYER_XR: immersive-vr is not supported by this browser/device.');
|
||||
}
|
||||
Reference in New Issue
Block a user