From b674df1555afb1a7e6e3dfb577a984670cf18070 Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:12:17 +1000 Subject: [PATCH] Updated --- README.md | 81 ++++--- package.json | 2 +- src/vr180player/bootstrap.ts | 144 ++++------- src/vr180player/config.ts | 3 + src/vr180player/dom/fallback-modal.ts | 76 ++++++ src/vr180player/dom/icons.ts | 7 +- .../launcher/launcher-bootstrap.ts | 123 ++++++++++ src/vr180player/launcher/launcher-config.ts | 223 ++++++++++++++++++ src/vr180player/styles/vr180-player.css | 65 +++++ src/vr180player/vr180-player.ts | 119 +++++++--- src/vr180player/xr/xr-support.ts | 53 +++++ test-pages/demo.css | 58 +++++ test-pages/index.html | 130 +++++++--- test-pages/test-3d-image-carousel.html | 17 +- test-pages/test-3d-image.html | 15 +- test-pages/test-3d-video.html | 19 +- test-pages/test-gallery-launchers.html | 84 +++++++ test-pages/test-vr180-3d-image-carousel.html | 17 +- test-pages/test-vr180-3d-image.html | 15 +- test-pages/test-vr180-3d-video.html | 19 +- tests/launcher.test.mjs | 108 +++++++++ 21 files changed, 1176 insertions(+), 202 deletions(-) create mode 100644 src/vr180player/dom/fallback-modal.ts create mode 100644 src/vr180player/launcher/launcher-bootstrap.ts create mode 100644 src/vr180player/launcher/launcher-config.ts create mode 100644 src/vr180player/xr/xr-support.ts create mode 100644 test-pages/test-gallery-launchers.html create mode 100644 tests/launcher.test.mjs diff --git a/README.md b/README.md index c7b3653..cea9692 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,68 @@ The player supports two projection modes: Build the player first, then host the generated `vr180player/` directory on your CDN and include the module script. The script automatically loads its matching CSS file from the same folder, and it imports its helper modules with relative module paths. ```html -
- -
+ ``` -Use `data-projection="plane"` for flat 3D video on a rectangular plane: +A page can contain any number of launchers. Each launcher represents one SBS media item. A launcher click goes straight into immersive WebXR when `immersive-vr` is supported. When immersive WebXR is unavailable, the same click opens a modal with the left-eye fallback view. + +Launcher attributes: + +- `data-vr-web-launcher`: required marker. +- `data-src`: required media URL. For image carousels, provide at least two comma-separated image URLs. +- `data-media-type="image|video"`: optional when the media type can be inferred from the URL extension. +- `data-projection="vr180|plane"`: defaults to `vr180`. +- `data-title`: optional display title. +- `data-carousel`: optional image carousel mode. +- `data-head-lock="auto|position|none"`: optional positional comfort mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen. +- `data-poster`, `data-type`, and `data-preload`: video helpers. +- `data-crossorigin`: optional media CORS mode, usually `anonymous` for CDN media. + +Use `data-projection="plane"` for flat 3D media on a rectangular plane: ```html -
- -
+ ``` -Use `data-head-lock="auto|position|none"` to control positional comfort in immersive mode. It defaults to `auto`, which position-locks `vr180` media to the headset to avoid false 6DoF parallax, while leaving `plane` media fixed like a screen. Use `position` to force locking for either projection, or `none` to keep all media world-fixed. - -Use an `img` element for a static SBS image: +Use `data-carousel` for multiple SBS still images in one immersive session: ```html -
- Demo image -
+ ``` -Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session: - -```html -
- First image - Second image -
-``` - -Only one player is supported per page in this version. The player logs a clear console message and does not initialize if no `[data-vr-web-player]` container is found, if multiple containers are found, if the container does not contain exactly one supported video/image element, if an image carousel does not contain at least two images and no videos, or if `data-projection` is not `vr180` or `plane`. +`[data-vr-web-player]` is now an internal container created by the launcher at runtime. Authored pages should use `[data-vr-web-launcher]`. ## Media format This version supports side-by-side media only: @@ -59,7 +82,7 @@ This version supports side-by-side media only: It does not support over-under, MV-HEVC, APMP, or `.aivu`. ## How it works -When the page loads, the media is embedded normally with an entry button over it. When the user clicks the button, the player checks for `navigator.xr` and `immersive-vr` support. +When the page loads, the script binds every `[data-vr-web-launcher]` on the page. When the user clicks a launcher, the player checks for `navigator.xr` and `immersive-vr` support, then splits between immersive entry and fallback modal display. - In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere. In the default `auto` head-lock mode, the sphere follows headset position but not headset rotation. - In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane. @@ -71,7 +94,7 @@ When the page loads, the media is embedded normally with an entry button over it - Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required. ## Demo -Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub links to focused pages for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video. The root `index.html` redirects there for convenience. +Run `npm run build`, then open `test-pages/index.html` through a local web server. The test hub is a gallery with launchers for flat 3D image, VR180 3D image, image carousels, flat 3D video, and VR180 3D video. For local experimentation, run: diff --git a/package.json b/package.json index f0381e8..8c9bf3b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc && node scripts/copy-styles.mjs", "check": "tsc --noEmit", "deploy:r2": "npm run build && npm run upload:r2", - "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs", + "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs tests/launcher.test.mjs", "preview": "npm run build && vite preview --host 127.0.0.1", "upload:r2": "node scripts/upload-r2.mjs", "upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run" diff --git a/src/vr180player/bootstrap.ts b/src/vr180player/bootstrap.ts index d058b6e..a92e8ff 100644 --- a/src/vr180player/bootstrap.ts +++ b/src/vr180player/bootstrap.ts @@ -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(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; -} diff --git a/src/vr180player/config.ts b/src/vr180player/config.ts index 09dc10c..0f32ba6 100644 --- a/src/vr180player/config.ts +++ b/src/vr180player/config.ts @@ -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(['vr180', 'plane']); export const VALID_HEAD_LOCKS = new Set(['auto', 'position', 'none']); +export const VALID_LAUNCHER_MEDIA_TYPES = new Set(['image', 'video']); export const PLANE_WIDTH = 3.2; export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16); diff --git a/src/vr180player/dom/fallback-modal.ts b/src/vr180player/dom/fallback-modal.ts new file mode 100644 index 0000000..0802fc0 --- /dev/null +++ b/src/vr180player/dom/fallback-modal.ts @@ -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('.vrwp-modal-close')?.focus(); + } + + setContent(element: HTMLElement): void { + this.content.replaceChildren(element); + } + + private readonly onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + this.close(); + } + }; +} diff --git a/src/vr180player/dom/icons.ts b/src/vr180player/dom/icons.ts index a6784a5..0b3e4c6 100644 --- a/src/vr180player/dom/icons.ts +++ b/src/vr180player/dom/icons.ts @@ -11,7 +11,8 @@ export type LucideIconName = | 'repeat' | 'volume-2' | 'volume-x' - | 'log-out'; + | 'log-out' + | 'x'; type IconAttrs = Record; type IconNode = readonly [tagName: string, attrs: IconAttrs]; @@ -74,6 +75,10 @@ const ICONS: Record = { ['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' }] ] }; diff --git a/src/vr180player/launcher/launcher-bootstrap.ts b/src/vr180player/launcher/launcher-bootstrap.ts new file mode 100644 index 0000000..64c3abb --- /dev/null +++ b/src/vr180player/launcher/launcher-bootstrap.ts @@ -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; + 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(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 { + 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('.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; +} diff --git a/src/vr180player/launcher/launcher-config.ts b/src/vr180player/launcher/launcher-config.ts new file mode 100644 index 0000000..90a408d --- /dev/null +++ b/src/vr180player/launcher/launcher-config.ts @@ -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; +} diff --git a/src/vr180player/styles/vr180-player.css b/src/vr180player/styles/vr180-player.css index ffd136c..84db74b 100644 --- a/src/vr180player/styles/vr180-player.css +++ b/src/vr180player/styles/vr180-player.css @@ -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; diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index f35d9ad..d66fd37 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -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 { + if (this.playBtn.dataset.xrSupported === 'true') { + await this.enterImmersive(); + return; + } + + this.showFallback(); + } + + async enterImmersive(): Promise { + 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 { - 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 { + private async actualSessionToggle(): Promise { 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.`); }); diff --git a/src/vr180player/xr/xr-support.ts b/src/vr180player/xr/xr-support.ts new file mode 100644 index 0000000..ca44f60 --- /dev/null +++ b/src/vr180player/xr/xr-support.ts @@ -0,0 +1,53 @@ +let immersiveVrSupportPromise: Promise | undefined; + +export function getImmersiveVrSupport(): Promise { + 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 { + const supported = await getImmersiveVrSupport(); + applyKnownImmersiveVrSupport(playButton, supported); + + if (!supported) { + logImmersiveVrUnsupported(); + } + + return supported; +} + +function checkImmersiveVrSupport(): Promise { + 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.'); +} diff --git a/test-pages/demo.css b/test-pages/demo.css index 502a21f..6efa4c9 100644 --- a/test-pages/demo.css +++ b/test-pages/demo.css @@ -146,6 +146,64 @@ a { color: #275425; } +.demo-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 16px; +} + +.demo-gallery-tile { + display: grid; + gap: 10px; + padding: 0 0 12px; + border: 1px solid #d5d5cf; + border-radius: 8px; + background: #fff; + color: inherit; + font: inherit; + font-weight: 700; + text-align: left; + cursor: pointer; + overflow: hidden; +} + +.demo-gallery-tile:hover { + border-color: #83837a; +} + +.demo-gallery-tile img { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + background: #111; +} + +.demo-gallery-tile span { + padding: 0 12px; +} + +.demo-focused-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 22px; +} + +.demo-focused-links a { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid #d5d5cf; + border-radius: 6px; + background: #fff; + color: #4f4f48; + text-decoration: none; + font-size: 0.9rem; + font-weight: 650; +} + @media (max-width: 640px) { .demo-page { padding: 20px; diff --git a/test-pages/index.html b/test-pages/index.html index b8d861c..1d5b9d6 100644 --- a/test-pages/index.html +++ b/test-pages/index.html @@ -12,45 +12,111 @@

VR Web Player Tests

-

Open a focused page for each media and projection combination.

+

Click a thumbnail. XR-capable browsers launch immersive VR; other browsers open the fallback modal.

-