forked from EXT/VR180-Web-Player
81
README.md
81
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.
|
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
|
```html
|
||||||
<div data-vr-web-player data-projection="vr180">
|
<button
|
||||||
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
|
type="button"
|
||||||
<source src="sbs-video.mp4" type="video/mp4">
|
data-vr-web-launcher
|
||||||
</video>
|
data-media-type="image"
|
||||||
</div>
|
data-projection="vr180"
|
||||||
|
data-src="vr180-sbs-image.jpg"
|
||||||
|
data-title="Temple Hall"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="temple-thumb.jpg" alt="Temple Hall">
|
||||||
|
</button>
|
||||||
|
|
||||||
<script type="module" src="https://cdn.example.com/vr180player/vr180-player.js"></script>
|
<script type="module" src="https://cdn.example.com/vr180player/vr180-player.js"></script>
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
```html
|
||||||
<div data-vr-web-player data-projection="plane">
|
<button
|
||||||
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
|
type="button"
|
||||||
<source src="sbs-video.mp4" type="video/mp4">
|
data-vr-web-launcher
|
||||||
</video>
|
data-media-type="video"
|
||||||
</div>
|
data-projection="plane"
|
||||||
|
data-src="flat-sbs-video.mp4"
|
||||||
|
data-poster="poster.jpg"
|
||||||
|
data-title="Flat 3D Demo"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="poster.jpg" alt="Flat 3D Demo">
|
||||||
|
</button>
|
||||||
```
|
```
|
||||||
|
|
||||||
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 `data-carousel` for multiple SBS still images in one immersive session:
|
||||||
|
|
||||||
Use an `img` element for a static SBS image:
|
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div data-vr-web-player data-projection="plane">
|
<button
|
||||||
<img src="sbs-image.png" alt="Demo image" title="Demo Image" crossorigin="anonymous">
|
type="button"
|
||||||
</div>
|
data-vr-web-launcher
|
||||||
|
data-carousel
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="first-sbs-image.png, second-sbs-image.png"
|
||||||
|
data-title="VR180 Stills"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="first-thumb.jpg" alt="VR180 Stills">
|
||||||
|
</button>
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `data-carousel` to an image player when you want previous/next controls for multiple SBS still images in one immersive session:
|
`[data-vr-web-player]` is now an internal container created by the launcher at runtime. Authored pages should use `[data-vr-web-launcher]`.
|
||||||
|
|
||||||
```html
|
|
||||||
<div data-vr-web-player data-projection="vr180" data-carousel>
|
|
||||||
<img src="first-sbs-image.png" alt="First image" title="First Image" crossorigin="anonymous">
|
|
||||||
<img src="second-sbs-image.png" alt="Second image" title="Second Image" crossorigin="anonymous">
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
## Media format
|
## Media format
|
||||||
This version supports side-by-side media only:
|
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`.
|
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
|
||||||
|
|
||||||
## How it works
|
## 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, `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.
|
- 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.
|
- Control icons are embedded from [Lucide](https://lucide.dev/) SVG definitions, so no PNG icon assets are required.
|
||||||
|
|
||||||
## Demo
|
## 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:
|
For local experimentation, run:
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "tsc && node scripts/copy-styles.mjs",
|
"build": "tsc && node scripts/copy-styles.mjs",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"deploy:r2": "npm run build && npm run upload:r2",
|
"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",
|
"preview": "npm run build && vite preview --host 127.0.0.1",
|
||||||
"upload:r2": "node scripts/upload-r2.mjs",
|
"upload:r2": "node scripts/upload-r2.mjs",
|
||||||
"upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run"
|
"upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run"
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
VALID_HEAD_LOCKS,
|
VALID_HEAD_LOCKS,
|
||||||
VALID_PROJECTIONS
|
VALID_PROJECTIONS
|
||||||
} from './config.js';
|
} 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 { createMediaAdapter, type SupportedMediaAdapter } from './media/media-adapter.js';
|
||||||
|
import { applyKnownImmersiveVrSupport } from './xr/xr-support.js';
|
||||||
|
|
||||||
export type BootstrapContext = {
|
export type BootstrapContext = {
|
||||||
headLockMode: HeadLockMode;
|
headLockMode: HeadLockMode;
|
||||||
@@ -18,47 +19,40 @@ export type BootstrapContext = {
|
|||||||
projectionMode: ProjectionMode;
|
projectionMode: ProjectionMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function bootstrapPlayer(playerBase: string, onReady: (context: BootstrapContext) => void): void {
|
type CreatePlayerContextOptions = {
|
||||||
injectPlayerStyles(playerBase);
|
immersiveVrSupported?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
onDocumentReady(() => {
|
export function createPlayerContext(playerContainer: HTMLElement, options: CreatePlayerContextOptions = {}): BootstrapContext | null {
|
||||||
const containers = document.querySelectorAll<HTMLElement>(PLAYER_SELECTOR);
|
|
||||||
|
|
||||||
if (containers.length === 0) {
|
|
||||||
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
playerContainer.classList.add('vrwp');
|
||||||
|
|
||||||
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
|
||||||
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
|
||||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
|
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
const configuredHeadLock = (playerContainer.dataset.headLock || DEFAULT_HEAD_LOCK).trim().toLowerCase();
|
||||||
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
if (!VALID_HEAD_LOCKS.has(configuredHeadLock as HeadLockMode)) {
|
||||||
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-head-lock="${configuredHeadLock}". Use "auto", "position", or "none".`);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaAdapter = createMediaAdapter(playerContainer);
|
const mediaAdapter = createMediaAdapter(playerContainer);
|
||||||
if (!mediaAdapter) {
|
if (!mediaAdapter) {
|
||||||
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
console.error(`VR_WEB_PLAYER_DOM: Internal ${PLAYER_SELECTOR} container must contain exactly one video/img, or multiple img elements with data-carousel.`);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playButton = createPlayButton();
|
const playButton = createPlayButton();
|
||||||
playerContainer.appendChild(playButton);
|
playerContainer.appendChild(playButton);
|
||||||
playerContainer.appendChild(create2DControlPanel());
|
playerContainer.appendChild(create2DControlPanel());
|
||||||
playButton.disabled = true;
|
playButton.disabled = true;
|
||||||
|
|
||||||
|
if (options.immersiveVrSupported !== undefined) {
|
||||||
|
applyKnownImmersiveVrSupport(playButton, options.immersiveVrSupported);
|
||||||
|
}
|
||||||
|
|
||||||
mediaAdapter.bindLoadState({
|
mediaAdapter.bindLoadState({
|
||||||
onError: (event) => {
|
onError: (event) => {
|
||||||
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
|
||||||
@@ -70,19 +64,16 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
|
|||||||
});
|
});
|
||||||
mediaAdapter.load();
|
mediaAdapter.load();
|
||||||
|
|
||||||
completeXrSupportCheck(playButton, () => {
|
return {
|
||||||
onReady({
|
|
||||||
headLockMode: configuredHeadLock as HeadLockMode,
|
headLockMode: configuredHeadLock as HeadLockMode,
|
||||||
mediaAdapter,
|
mediaAdapter,
|
||||||
playButton,
|
playButton,
|
||||||
playerContainer,
|
playerContainer,
|
||||||
projectionMode: configuredProjection as ProjectionMode
|
projectionMode: configuredProjection as ProjectionMode
|
||||||
});
|
};
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDocumentReady(callback: () => void): void {
|
export function onDocumentReady(callback: () => void): void {
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||||
return;
|
return;
|
||||||
@@ -90,36 +81,3 @@ function onDocumentReady(callback: () => void): void {
|
|||||||
|
|
||||||
callback();
|
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 PLAYER_SELECTOR = '[data-vr-web-player]';
|
||||||
|
export const LAUNCHER_SELECTOR = '[data-vr-web-launcher]';
|
||||||
|
|
||||||
export type ProjectionMode = 'vr180' | 'plane';
|
export type ProjectionMode = 'vr180' | 'plane';
|
||||||
export type HeadLockMode = 'auto' | 'position' | 'none';
|
export type HeadLockMode = 'auto' | 'position' | 'none';
|
||||||
|
export type LauncherMediaType = 'image' | 'video';
|
||||||
|
|
||||||
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
export const DEFAULT_PROJECTION: ProjectionMode = 'vr180';
|
||||||
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
export const DEFAULT_HEAD_LOCK: HeadLockMode = 'auto';
|
||||||
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
export const VALID_PROJECTIONS = new Set<ProjectionMode>(['vr180', 'plane']);
|
||||||
export const VALID_HEAD_LOCKS = new Set<HeadLockMode>(['auto', 'position', 'none']);
|
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_WIDTH = 3.2;
|
||||||
export const PLANE_HEIGHT = PLANE_WIDTH * (9 / 16);
|
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'
|
| 'repeat'
|
||||||
| 'volume-2'
|
| 'volume-2'
|
||||||
| 'volume-x'
|
| 'volume-x'
|
||||||
| 'log-out';
|
| 'log-out'
|
||||||
|
| 'x';
|
||||||
|
|
||||||
type IconAttrs = Record<string, string>;
|
type IconAttrs = Record<string, string>;
|
||||||
type IconNode = readonly [tagName: string, attrs: IconAttrs];
|
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' }],
|
['path', { d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4' }],
|
||||||
['polyline', { points: '16 17 21 12 16 7' }],
|
['polyline', { points: '16 17 21 12 16 7' }],
|
||||||
['line', { x1: '21', y1: '12', x2: '9', y2: '12' }]
|
['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));
|
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) {
|
@media (max-width: 600px) {
|
||||||
.vrwp-play-button {
|
.vrwp-play-button {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
LAUNCHER_SELECTOR,
|
||||||
PLANE_2D_DISTANCE,
|
PLANE_2D_DISTANCE,
|
||||||
PLANE_DISTANCE,
|
PLANE_DISTANCE,
|
||||||
|
PLAYER_SELECTOR,
|
||||||
type HeadLockMode,
|
type HeadLockMode,
|
||||||
type ProjectionMode
|
type ProjectionMode
|
||||||
} from './config.js';
|
} 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 { createContentScene } from './rendering/content-scene.js';
|
||||||
import {
|
import {
|
||||||
applyHeadPositionLock as applyHeadPositionLockCore,
|
applyHeadPositionLock as applyHeadPositionLockCore,
|
||||||
@@ -42,6 +49,7 @@ import {
|
|||||||
PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
PLAYING_VIDEO_2D_CONTROL_AUTO_HIDE_DELAY_MS,
|
||||||
getVideoAwareAutoHideDelayMs
|
getVideoAwareAutoHideDelayMs
|
||||||
} from './utils/control-panel-timing.js';
|
} from './utils/control-panel-timing.js';
|
||||||
|
import { setupLauncherButtons } from './launcher/launcher-bootstrap.js';
|
||||||
|
|
||||||
export class PlayerSession {
|
export class PlayerSession {
|
||||||
private readonly headLockMode: HeadLockMode;
|
private readonly headLockMode: HeadLockMode;
|
||||||
@@ -51,7 +59,11 @@ export class PlayerSession {
|
|||||||
private readonly projectionMode: ProjectionMode;
|
private readonly projectionMode: ProjectionMode;
|
||||||
private readonly uiElements: any[] = [];
|
private readonly uiElements: any[] = [];
|
||||||
private readonly vrPanelVisibility = new VrPanelVisibility();
|
private readonly vrPanelVisibility = new VrPanelVisibility();
|
||||||
|
private readonly handleEnterButtonClick = () => {
|
||||||
|
void this.enterOrShowFallback();
|
||||||
|
};
|
||||||
private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event);
|
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 readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame);
|
||||||
|
|
||||||
private activeContentMesh: any;
|
private activeContentMesh: any;
|
||||||
@@ -172,10 +184,8 @@ export class PlayerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.playBtn.addEventListener('click', () => {
|
this.playBtn.addEventListener('click', this.handleEnterButtonClick);
|
||||||
void this.handleEnterVRButtonClick();
|
window.addEventListener('resize', this.handleWindowResize);
|
||||||
});
|
|
||||||
window.addEventListener('resize', () => this.onWindowResize());
|
|
||||||
|
|
||||||
if (this.video) {
|
if (this.video) {
|
||||||
bindVideoEvents({
|
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 {
|
private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void {
|
||||||
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
|
applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive());
|
||||||
}
|
}
|
||||||
@@ -487,27 +541,10 @@ export class PlayerSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEnterVRButtonClick(): Promise<void> {
|
private async actualSessionToggle(): Promise<boolean> {
|
||||||
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> {
|
|
||||||
if (!this.renderer || !this.renderer.isWebGLRenderer) {
|
if (!this.renderer || !this.renderer.isWebGLRenderer) {
|
||||||
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
|
console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.xrSession) {
|
if (this.xrSession) {
|
||||||
@@ -521,7 +558,7 @@ export class PlayerSession {
|
|||||||
console.error('Error calling .end() on session:', err);
|
console.error('Error calling .end() on session:', err);
|
||||||
this.onVRSessionEnd({ session: sessionToClose });
|
this.onVRSessionEnd({ session: sessionToClose });
|
||||||
});
|
});
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -569,6 +606,7 @@ export class PlayerSession {
|
|||||||
this.isXrLoopActive = true;
|
this.isXrLoopActive = true;
|
||||||
this.renderer.setAnimationLoop(this.renderXrFrame);
|
this.renderer.setAnimationLoop(this.renderXrFrame);
|
||||||
this.frameCounter = 0;
|
this.frameCounter = 0;
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
|
const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err));
|
||||||
console.error(sessionStartError, err);
|
console.error(sessionStartError, err);
|
||||||
@@ -592,6 +630,7 @@ export class PlayerSession {
|
|||||||
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
|
if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) {
|
||||||
this.renderer.setAnimationLoop(null);
|
this.renderer.setAnimationLoop(null);
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,7 +743,31 @@ export class PlayerSession {
|
|||||||
const playerBase = new URL('.', import.meta.url).href;
|
const playerBase = new URL('.', import.meta.url).href;
|
||||||
let activeSession: PlayerSession | undefined;
|
let activeSession: PlayerSession | undefined;
|
||||||
|
|
||||||
bootstrapPlayer(playerBase, (context) => {
|
injectPlayerStyles(playerBase);
|
||||||
|
|
||||||
|
onDocumentReady(() => {
|
||||||
|
const initialized = setupLauncherButtons({
|
||||||
|
createSession: (playerContainer, immersiveVrSupported) => {
|
||||||
|
const context = createPlayerContext(playerContainer, { immersiveVrSupported });
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
activeSession = new PlayerSession(context);
|
activeSession = new PlayerSession(context);
|
||||||
activeSession.init();
|
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.');
|
||||||
|
}
|
||||||
@@ -146,6 +146,64 @@ a {
|
|||||||
color: #275425;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.demo-page {
|
.demo-page {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
@@ -12,45 +12,111 @@
|
|||||||
<header class="demo-topbar">
|
<header class="demo-topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="demo-brand">VR Web Player Tests</h1>
|
<h1 class="demo-brand">VR Web Player Tests</h1>
|
||||||
<p class="demo-kicker">Open a focused page for each media and projection combination.</p>
|
<p class="demo-kicker">Click a thumbnail. XR-capable browsers launch immersive VR; other browsers open the fallback modal.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="demo-grid" aria-label="Player test pages">
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
<a class="demo-card" href="./test-3d-image.html">
|
|
||||||
<h2>3D Image</h2>
|
<div class="demo-gallery-grid" aria-label="Launcher gallery">
|
||||||
<p>Flat SBS image on a rectangular plane.</p>
|
<button
|
||||||
<span class="demo-meta">image / plane</span>
|
type="button"
|
||||||
</a>
|
class="demo-gallery-tile"
|
||||||
<a class="demo-card" href="./test-vr180-3d-image.html">
|
data-vr-web-launcher
|
||||||
<h2>VR180 3D Image</h2>
|
data-media-type="image"
|
||||||
<p>SBS image on the VR180 hemisphere.</p>
|
data-projection="plane"
|
||||||
<span class="demo-meta">image / vr180</span>
|
data-src="../media/169_3d_test.png"
|
||||||
</a>
|
data-title="3D Image Plane"
|
||||||
<a class="demo-card" href="./test-3d-image-carousel.html">
|
data-crossorigin="anonymous">
|
||||||
<h2>3D Image Carousel</h2>
|
<img src="../media/169_3d_test.png" alt="3D Image Plane">
|
||||||
<p>Flat SBS image carousel with previous and next controls.</p>
|
<span>3D Image</span>
|
||||||
<span class="demo-meta">image carousel / plane</span>
|
</button>
|
||||||
</a>
|
|
||||||
<a class="demo-card" href="./test-vr180-3d-image-carousel.html">
|
<button
|
||||||
<h2>VR180 Image Carousel</h2>
|
type="button"
|
||||||
<p>VR180 SBS image carousel in one immersive session.</p>
|
class="demo-gallery-tile"
|
||||||
<span class="demo-meta">image carousel / vr180</span>
|
data-vr-web-launcher
|
||||||
</a>
|
data-media-type="image"
|
||||||
<a class="demo-card" href="./test-3d-video.html">
|
data-projection="vr180"
|
||||||
<h2>3D Video</h2>
|
data-src="../media/VR180_SBS_Test.png"
|
||||||
<p>Flat SBS video on a rectangular plane.</p>
|
data-title="VR180 3D Image"
|
||||||
<span class="demo-meta">video / plane</span>
|
data-crossorigin="anonymous">
|
||||||
</a>
|
<img src="../media/VR180_SBS_Test.png" alt="VR180 3D Image">
|
||||||
<a class="demo-card" href="./test-vr180-3d-video.html">
|
<span>VR180 Image</span>
|
||||||
<h2>VR180 3D Video</h2>
|
</button>
|
||||||
<p>SBS video on the VR180 hemisphere.</p>
|
|
||||||
<span class="demo-meta">video / vr180</span>
|
<button
|
||||||
</a>
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-carousel
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/169_3d_test.png, ../media/169_3d_test.png?slide=2"
|
||||||
|
data-title="3D Image Plane"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/169_3d_test.png" alt="3D Image Plane carousel">
|
||||||
|
<span>3D Image Carousel</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-carousel
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_Test.png, ../media/VR180_SBS_Test.png?slide=2"
|
||||||
|
data-title="VR180 3D Image"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/VR180_SBS_Test.png" alt="VR180 3D Image carousel">
|
||||||
|
<span>VR180 Image Carousel</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/sbs-video.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="3D Video Plane"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="3D Video Plane">
|
||||||
|
<span>3D Video</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_TEST.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="VR180 3D Video"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="VR180 3D Video">
|
||||||
|
<span>VR180 Video</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="demo-focused-links" aria-label="Focused player test pages">
|
||||||
|
<a href="./test-3d-image.html">3D image</a>
|
||||||
|
<a href="./test-vr180-3d-image.html">VR180 image</a>
|
||||||
|
<a href="./test-3d-image-carousel.html">3D image carousel</a>
|
||||||
|
<a href="./test-vr180-3d-image-carousel.html">VR180 image carousel</a>
|
||||||
|
<a href="./test-3d-video.html">3D video</a>
|
||||||
|
<a href="./test-vr180-3d-video.html">VR180 video</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p class="demo-note">Image tests use files in <code>../media/</code>. Video tests expect <code>../media/sbs-video.mp4</code>.</p>
|
<p class="demo-note">Image tests use files in <code>../media/</code>. Video tests expect <code>../media/sbs-video.mp4</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script type="module" src="./demo-xr-status.js"></script>
|
||||||
|
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -20,9 +20,20 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane" data-carousel>
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<img src="../media/169_3d_test.png" alt="Demo SBS image one" title="3D Image Plane 1" crossorigin="anonymous">
|
<button
|
||||||
<img src="../media/169_3d_test.png?slide=2" alt="Demo SBS image two" title="3D Image Plane 2" crossorigin="anonymous">
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-carousel
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/169_3d_test.png, ../media/169_3d_test.png?slide=2"
|
||||||
|
data-title="3D Image Plane"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/169_3d_test.png" alt="3D Image Plane carousel">
|
||||||
|
<span>Open 3D Image Carousel</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,8 +20,19 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<img src="../media/169_3d_test.png" alt="Demo SBS image" title="3D Image Plane" crossorigin="anonymous">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/169_3d_test.png"
|
||||||
|
data-title="3D Image Plane"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/169_3d_test.png" alt="3D Image Plane">
|
||||||
|
<span>Open 3D Image</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,10 +20,21 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<video poster="../poster.jpg" title="3D Video Plane" crossorigin="anonymous" playsinline preload="metadata">
|
<button
|
||||||
<source src="../media/sbs-video.mp4" type="video/mp4">
|
type="button"
|
||||||
</video>
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/sbs-video.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="3D Video Plane"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="3D Video Plane">
|
||||||
|
<span>Open 3D Video</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
84
test-pages/test-gallery-launchers.html
Normal file
84
test-pages/test-gallery-launchers.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>Gallery Launcher Test</title>
|
||||||
|
<link rel="stylesheet" href="./demo.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="demo-page">
|
||||||
|
<div class="demo-shell">
|
||||||
|
<header class="demo-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 class="demo-brand">Gallery Launchers</h1>
|
||||||
|
<p class="demo-kicker">Click a thumbnail. XR-capable browsers launch immersive VR; other browsers open the fallback modal.</p>
|
||||||
|
</div>
|
||||||
|
<a class="demo-back" href="./index.html">Back</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
|
<div class="demo-gallery-grid" aria-label="Launcher gallery">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/169_3d_test.png"
|
||||||
|
data-title="3D Image Plane"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/169_3d_test.png" alt="3D Image Plane">
|
||||||
|
<span>3D Image</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_Test.png"
|
||||||
|
data-title="VR180 3D Image"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/VR180_SBS_Test.png" alt="VR180 3D Image">
|
||||||
|
<span>VR180 Image</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="plane"
|
||||||
|
data-src="../media/sbs-video.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="3D Video Plane"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="3D Video Plane">
|
||||||
|
<span>3D Video</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_TEST.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="VR180 3D Video"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="VR180 3D Video">
|
||||||
|
<span>VR180 Video</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="./demo-xr-status.js"></script>
|
||||||
|
<script type="module" src="../vr180player/vr180-player.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -20,9 +20,20 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180" data-carousel>
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image one" title="VR180 3D Image 1" crossorigin="anonymous">
|
<button
|
||||||
<img src="../media/VR180_SBS_Test.png?slide=2" alt="Demo VR180 SBS image two" title="VR180 3D Image 2" crossorigin="anonymous">
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-carousel
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_Test.png, ../media/VR180_SBS_Test.png?slide=2"
|
||||||
|
data-title="VR180 3D Image"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/VR180_SBS_Test.png" alt="VR180 3D Image carousel">
|
||||||
|
<span>Open VR180 Image Carousel</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,8 +20,19 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<img src="../media/VR180_SBS_Test.png" alt="Demo VR180 SBS image" title="VR180 3D Image" crossorigin="anonymous">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="image"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_Test.png"
|
||||||
|
data-title="VR180 3D Image"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../media/VR180_SBS_Test.png" alt="VR180 3D Image">
|
||||||
|
<span>Open VR180 Image</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -20,10 +20,21 @@
|
|||||||
|
|
||||||
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
<p class="demo-xr-status" data-demo-xr-status>Checking immersive WebXR support...</p>
|
||||||
|
|
||||||
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
|
<div class="demo-player-frame demo-gallery-grid">
|
||||||
<video poster="../poster.jpg" title="VR180 3D Video" crossorigin="anonymous" playsinline preload="metadata">
|
<button
|
||||||
<source src="../media/VR180_SBS_TEST.mp4" type="video/mp4">
|
type="button"
|
||||||
</video>
|
class="demo-gallery-tile"
|
||||||
|
data-vr-web-launcher
|
||||||
|
data-media-type="video"
|
||||||
|
data-projection="vr180"
|
||||||
|
data-src="../media/VR180_SBS_TEST.mp4"
|
||||||
|
data-poster="../poster.jpg"
|
||||||
|
data-title="VR180 3D Video"
|
||||||
|
data-type="video/mp4"
|
||||||
|
data-crossorigin="anonymous">
|
||||||
|
<img src="../poster.jpg" alt="VR180 3D Video">
|
||||||
|
<span>Open VR180 Video</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
108
tests/launcher.test.mjs
Normal file
108
tests/launcher.test.mjs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLauncherAction,
|
||||||
|
inferLauncherMediaType,
|
||||||
|
readLauncherMediaConfig
|
||||||
|
} from '../vr180player/launcher/launcher-config.js';
|
||||||
|
|
||||||
|
function createLauncher({ attributes = {}, dataset = {} } = {}) {
|
||||||
|
return {
|
||||||
|
dataset,
|
||||||
|
getAttribute(name) {
|
||||||
|
return attributes[name] ?? '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getLauncherAction opens immersive only when immersive VR is supported', () => {
|
||||||
|
assert.equal(getLauncherAction(true), 'immersive');
|
||||||
|
assert.equal(getLauncherAction(false), 'fallback-modal');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inferLauncherMediaType detects common image and video extensions', () => {
|
||||||
|
assert.equal(inferLauncherMediaType('https://cdn.example.com/demo.png'), 'image');
|
||||||
|
assert.equal(inferLauncherMediaType('/media/demo.JPG?cache=1'), 'image');
|
||||||
|
assert.equal(inferLauncherMediaType('/media/demo.webm'), 'video');
|
||||||
|
assert.equal(inferLauncherMediaType('/media/demo.mp4#clip'), 'video');
|
||||||
|
assert.equal(inferLauncherMediaType('/media/demo'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLauncherMediaConfig reads explicit launcher data', () => {
|
||||||
|
const config = readLauncherMediaConfig(createLauncher({
|
||||||
|
dataset: {
|
||||||
|
crossorigin: 'anonymous',
|
||||||
|
headLock: 'position',
|
||||||
|
mediaType: 'video',
|
||||||
|
poster: '/poster.jpg',
|
||||||
|
preload: 'auto',
|
||||||
|
projection: 'plane',
|
||||||
|
src: '/media/demo',
|
||||||
|
title: 'Demo Video',
|
||||||
|
type: 'video/mp4'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.deepEqual(config, {
|
||||||
|
carousel: false,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
headLockMode: 'position',
|
||||||
|
mediaType: 'video',
|
||||||
|
poster: '/poster.jpg',
|
||||||
|
preload: 'auto',
|
||||||
|
projectionMode: 'plane',
|
||||||
|
src: '/media/demo',
|
||||||
|
srcs: ['/media/demo'],
|
||||||
|
title: 'Demo Video',
|
||||||
|
type: 'video/mp4'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLauncherMediaConfig infers defaults from data-src and aria-label', () => {
|
||||||
|
const config = readLauncherMediaConfig(createLauncher({
|
||||||
|
attributes: {
|
||||||
|
'aria-label': 'Demo Image'
|
||||||
|
},
|
||||||
|
dataset: {
|
||||||
|
src: '/media/demo-image.webp'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(config.mediaType, 'image');
|
||||||
|
assert.equal(config.projectionMode, 'vr180');
|
||||||
|
assert.equal(config.headLockMode, 'auto');
|
||||||
|
assert.equal(config.preload, 'metadata');
|
||||||
|
assert.equal(config.carousel, false);
|
||||||
|
assert.deepEqual(config.srcs, ['/media/demo-image.webp']);
|
||||||
|
assert.equal(config.title, 'Demo Image');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLauncherMediaConfig supports image carousel launchers', () => {
|
||||||
|
const config = readLauncherMediaConfig(createLauncher({
|
||||||
|
dataset: {
|
||||||
|
carousel: '',
|
||||||
|
mediaType: 'image',
|
||||||
|
projection: 'plane',
|
||||||
|
src: '/media/first.png, /media/second.png',
|
||||||
|
title: 'Demo Carousel'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert.equal(config.carousel, true);
|
||||||
|
assert.equal(config.mediaType, 'image');
|
||||||
|
assert.equal(config.projectionMode, 'plane');
|
||||||
|
assert.equal(config.src, '/media/first.png');
|
||||||
|
assert.deepEqual(config.srcs, ['/media/first.png', '/media/second.png']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLauncherMediaConfig rejects missing source or unsupported values', () => {
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher()), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { projection: 'cube', src: '/media/demo.png' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { headLock: 'bad', src: '/media/demo.png' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { mediaType: 'audio', src: '/media/demo.png' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { src: '/media/demo' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { carousel: '', mediaType: 'video', src: '/media/demo.mp4, /media/other.mp4' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { carousel: '', src: '/media/demo.png' } })), null);
|
||||||
|
assert.equal(readLauncherMediaConfig(createLauncher({ dataset: { src: '/media/first.png, /media/second.png' } })), null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user