1
0

Updated
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
Aiden
2026-06-11 09:12:17 +10:00
parent 1d4b3ce307
commit b674df1555
21 changed files with 1176 additions and 202 deletions

View File

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

View File

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

View 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();
}
};
}

View File

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

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

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

View File

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

View File

@@ -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.`);
});

View 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.');
}