1
0

2 Commits

Author SHA1 Message Date
Aiden
8402fcd640 new test hub
Some checks failed
Test / test (push) Has been cancelled
2026-06-10 12:51:31 +10:00
Aiden
030a8b724b added image support 2026-06-10 12:48:36 +10:00
18 changed files with 765 additions and 189 deletions

View File

@@ -1,10 +1,10 @@
# VR Web Player # VR Web Player
A CDN-friendly web player for side-by-side stereoscopic video. A CDN-friendly web player for side-by-side stereoscopic video and still images.
The player supports two projection modes: The player supports two projection modes:
- `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers. - `vr180`: an immersive 180 degree stereoscopic hemisphere in WebXR, with a draggable rectilinear fallback on non-XR browsers.
- `plane`: a flat stereoscopic video plane in WebXR, with a normal flat left-eye fallback on non-XR browsers. - `plane`: a flat stereoscopic media plane in WebXR, with a normal flat left-eye fallback on non-XR browsers.
## How to use it ## How to use it
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.
@@ -29,21 +29,35 @@ Use `data-projection="plane"` for flat 3D video on a rectangular plane:
</div> </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 has no video, or if `data-projection` is not `vr180` or `plane`. Use an `img` element for a static SBS image:
## Video format ```html
This version supports 2:1 side-by-side video using H.264 or HEVC in an mp4 file. It does not support over-under, MV-HEVC, APMP, or `.aivu`. <div data-vr-web-player data-projection="plane">
<img src="sbs-image.png" alt="Demo image" title="Demo 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 media element, or if `data-projection` is not `vr180` or `plane`.
## Media format
This version supports side-by-side media only:
- Video: 2:1 side-by-side video using H.264 or HEVC in an mp4 file.
- Image: side-by-side still images in browser-supported image formats such as PNG, JPEG, or WebP.
It does not support over-under, MV-HEVC, APMP, or `.aivu`.
## How it works ## How it works
When the page loads, the video is embedded normally with a play button over the poster frame. When the user clicks play, the player checks for `navigator.xr` and `immersive-vr` support. 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.
- In WebXR, `vr180` maps the left and right halves of the SBS video onto the matching eyes of a 180 degree sphere. - In WebXR, `vr180` maps the left and right halves of the SBS media onto the matching eyes of a 180 degree sphere.
- In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 video plane. - In WebXR, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 plane.
- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image. - Outside WebXR, both modes render only the left half of the SBS media so viewers do not see the raw double image.
- Static images show only applicable controls; playback, seek, and mute controls are video-only.
- 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 this repository's `index.html` through a local web server and switch `data-projection` between `vr180` and `plane` to test both modes. Run `npm run build`, then open this repository's `index.html` through a local web server. The index page links to focused test pages for flat 3D image, VR180 3D image, flat 3D video, and VR180 3D video.
For local experimentation, run: For local experimentation, run:

139
demo.css Normal file
View File

@@ -0,0 +1,139 @@
:root {
color-scheme: light;
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #151515;
background: #f4f4f2;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
}
.demo-page {
min-height: 100vh;
padding: 32px;
}
.demo-shell {
width: min(100%, 1040px);
margin: 0 auto;
}
.demo-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.demo-brand {
margin: 0;
font-size: 2.5rem;
line-height: 1;
}
.demo-kicker {
margin: 8px 0 0;
max-width: 700px;
color: #555;
font-size: 1rem;
}
.demo-back {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid #c7c7c0;
border-radius: 6px;
background: #fff;
text-decoration: none;
font-weight: 650;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 28px;
}
.demo-card {
display: grid;
gap: 10px;
min-height: 150px;
padding: 18px;
border: 1px solid #d5d5cf;
border-radius: 8px;
background: #fff;
text-decoration: none;
transition: transform 0.15s ease, border-color 0.15s ease;
}
.demo-card:hover {
transform: translateY(-2px);
border-color: #83837a;
}
.demo-card h2 {
margin: 0;
font-size: 1.125rem;
}
.demo-card p,
.demo-meta {
margin: 0;
color: #5c5c55;
}
.demo-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
color: #66665f;
font-size: 0.875rem;
font-weight: 650;
}
.demo-player {
display: grid;
gap: 14px;
}
.demo-player-frame {
width: min(100%, 960px);
}
.demo-note {
max-width: 760px;
margin: 18px 0 0;
color: #606058;
font-size: 0.95rem;
}
@media (max-width: 640px) {
.demo-page {
padding: 20px;
}
.demo-topbar {
align-items: flex-start;
flex-direction: column;
}
.demo-brand {
font-size: 2rem;
}
}

View File

@@ -3,31 +3,44 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR Web Player</title> <title>VR Web Player Test Pages</title>
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet> <link rel="stylesheet" href="./demo.css">
<style>
body {
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 1rem;
font-weight: normal;
}
main {
max-width: 750px;
margin: auto;
}
</style>
</head> </head>
<body> <body>
<main> <main class="demo-page">
<h1>VR Web Player</h1> <div class="demo-shell">
<p>This is a web-based player for side-by-side stereoscopic video.</p> <header class="demo-topbar">
<div data-vr-web-player data-projection="vr180"> <div>
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata"> <h1 class="demo-brand">VR Web Player Tests</h1>
<source src="sbs-video.mp4" type="video/mp4"> <p class="demo-kicker">Open a focused page for each media and projection combination.</p>
</video> </div>
</header>
<nav class="demo-grid" aria-label="Player test pages">
<a class="demo-card" href="./test-3d-image.html">
<h2>3D Image</h2>
<p>Flat SBS image on a rectangular plane.</p>
<span class="demo-meta">image / plane</span>
</a>
<a class="demo-card" href="./test-vr180-3d-image.html">
<h2>VR180 3D Image</h2>
<p>SBS image on the VR180 hemisphere.</p>
<span class="demo-meta">image / vr180</span>
</a>
<a class="demo-card" href="./test-3d-video.html">
<h2>3D Video</h2>
<p>Flat SBS video on a rectangular plane.</p>
<span class="demo-meta">video / plane</span>
</a>
<a class="demo-card" href="./test-vr180-3d-video.html">
<h2>VR180 3D Video</h2>
<p>SBS video on the VR180 hemisphere.</p>
<span class="demo-meta">video / vr180</span>
</a>
</nav>
<p class="demo-note">Image tests use <code>media/169_3d_test.png</code>. Video tests expect <code>media/sbs-video.mp4</code>.</p>
</div> </div>
</main> </main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body> </body>
</html> </html>

View File

@@ -41,7 +41,7 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
const mediaAdapter = createMediaAdapter(playerContainer); const mediaAdapter = createMediaAdapter(playerContainer);
if (!mediaAdapter) { if (!mediaAdapter) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a supported media element (video).`); console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain exactly one supported media element: video or img.`);
return; return;
} }
@@ -49,6 +49,15 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
playerContainer.appendChild(playButton); playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel()); playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true; 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(); mediaAdapter.load();
completeXrSupportCheck(playButton, () => { completeXrSupportCheck(playButton, () => {

View File

@@ -21,7 +21,7 @@ export function createPlayButton(): HTMLButtonElement {
const playButton = document.createElement('button'); const playButton = document.createElement('button');
playButton.type = 'button'; playButton.type = 'button';
playButton.className = 'vrwp-play-button'; playButton.className = 'vrwp-play-button';
playButton.setAttribute('aria-label', 'Play video'); playButton.setAttribute('aria-label', 'Open media');
playButton.appendChild(createLucideIcon('circle-play')); playButton.appendChild(createLucideIcon('circle-play'));
return playButton; return playButton;

View File

@@ -1,5 +1,6 @@
import { setLucideIcon } from './icons.js'; import { setLucideIcon } from './icons.js';
import { formatTime } from '../utils/time.js'; import { formatTime } from '../utils/time.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDControlPanelCallbacks = { type TwoDControlPanelCallbacks = {
onForward: () => void; onForward: () => void;
@@ -13,6 +14,7 @@ type TwoDControlPanelOptions = {
callbacks: TwoDControlPanelCallbacks; callbacks: TwoDControlPanelCallbacks;
fullscreenTarget: HTMLElement; fullscreenTarget: HTMLElement;
getIsActive: () => boolean; getIsActive: () => boolean;
mediaCapabilities: MediaCapabilities;
playerContainer: HTMLElement; playerContainer: HTMLElement;
title: string; title: string;
}; };
@@ -32,8 +34,10 @@ export class TwoDControlPanel {
private totalTimeDisplay: HTMLElement | null; private totalTimeDisplay: HTMLElement | null;
private playButton: HTMLButtonElement | null; private playButton: HTMLButtonElement | null;
private muteButton: HTMLButtonElement | null; private muteButton: HTMLButtonElement | null;
private navControls: HTMLElement | null;
private progressControls: HTMLElement | null;
constructor({ callbacks, fullscreenTarget, getIsActive, playerContainer, title }: TwoDControlPanelOptions) { constructor({ callbacks, fullscreenTarget, getIsActive, mediaCapabilities, playerContainer, title }: TwoDControlPanelOptions) {
this.callbacks = callbacks; this.callbacks = callbacks;
this.fullscreenTarget = fullscreenTarget; this.fullscreenTarget = fullscreenTarget;
this.getIsActive = getIsActive; this.getIsActive = getIsActive;
@@ -43,10 +47,12 @@ export class TwoDControlPanel {
const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title'); const videoTitle = playerContainer.querySelector<HTMLElement>('.vrwp-video-title');
this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time'); this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time');
this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time'); this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time');
this.progressControls = playerContainer.querySelector('.vrwp-progress');
this.progressBar = playerContainer.querySelector('.vrwp-bar'); this.progressBar = playerContainer.querySelector('.vrwp-bar');
this.playedBar = playerContainer.querySelector('.vrwp-played'); this.playedBar = playerContainer.querySelector('.vrwp-played');
this.playButton = playerContainer.querySelector('.vrwp-play-toggle'); this.playButton = playerContainer.querySelector('.vrwp-play-toggle');
this.muteButton = playerContainer.querySelector('.vrwp-mute'); this.muteButton = playerContainer.querySelector('.vrwp-mute');
this.navControls = playerContainer.querySelector('.vrwp-nav');
if (!this.controlPanel) { if (!this.controlPanel) {
console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.'); console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.');
@@ -57,7 +63,8 @@ export class TwoDControlPanel {
videoTitle.textContent = title; videoTitle.textContent = title;
} }
this.bindControls(playerContainer); this.applyCapabilities(mediaCapabilities);
this.bindControls(playerContainer, mediaCapabilities);
} }
show(): void { show(): void {
@@ -144,38 +151,58 @@ export class TwoDControlPanel {
} }
} }
private bindControls(playerContainer: HTMLElement): void { private applyCapabilities(mediaCapabilities: MediaCapabilities): void {
if (!mediaCapabilities.timeline && this.progressControls) {
this.progressControls.hidden = true;
}
if (!mediaCapabilities.playback && this.navControls) {
this.navControls.hidden = true;
}
if (!mediaCapabilities.audio && this.muteButton) {
this.muteButton.hidden = true;
}
}
private bindControls(playerContainer: HTMLElement, mediaCapabilities: MediaCapabilities): void {
playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => { playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => {
this.toggleFullscreen(); this.toggleFullscreen();
}); });
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => { if (mediaCapabilities.playback) {
this.callbacks.onRewind(); playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
this.show(); this.callbacks.onRewind();
}); this.show();
});
this.playButton?.addEventListener('click', () => { this.playButton?.addEventListener('click', () => {
this.callbacks.onPlayPause(); this.callbacks.onPlayPause();
this.show(); this.show();
}); });
playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => { playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => {
this.callbacks.onForward(); this.callbacks.onForward();
this.show(); this.show();
}); });
}
this.muteButton?.addEventListener('click', () => { if (mediaCapabilities.audio) {
this.callbacks.onMute(); this.muteButton?.addEventListener('click', () => {
this.show(); this.callbacks.onMute();
}); this.show();
});
}
this.progressBar?.addEventListener('click', (event) => { if (mediaCapabilities.timeline) {
const rect = this.progressBar?.getBoundingClientRect(); this.progressBar?.addEventListener('click', (event) => {
if (rect && rect.width > 0) { const rect = this.progressBar?.getBoundingClientRect();
this.callbacks.onSeek((event.clientX - rect.left) / rect.width); if (rect && rect.width > 0) {
} this.callbacks.onSeek((event.clientX - rect.left) / rect.width);
this.show(); }
}); this.show();
});
}
} }
private clearHideTimeout(): void { private clearHideTimeout(): void {

View File

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

View File

@@ -6,6 +6,7 @@ import {
showFallbackCanvas showFallbackCanvas
} from '../rendering/renderer-lifecycle.js'; } from '../rendering/renderer-lifecycle.js';
import { TwoDControlPanel } from '../dom/two-d-control-panel.js'; import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = { type TwoDModeCallbacks = {
createMediaTexture: () => any; createMediaTexture: () => any;
@@ -21,6 +22,7 @@ type TwoDModeCallbacks = {
type TwoDModeOptions = { type TwoDModeOptions = {
callbacks: TwoDModeCallbacks; callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement; fullscreenTarget: HTMLElement;
mediaCapabilities: MediaCapabilities;
getActiveContentMesh: () => any; getActiveContentMesh: () => any;
getCamera: () => any; getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined; getCameraControls: () => FallbackCameraControls | undefined;
@@ -40,6 +42,7 @@ export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks; private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel; private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement; private readonly fullscreenTarget: HTMLElement;
private readonly mediaCapabilities: MediaCapabilities;
private readonly getActiveContentMesh: () => any; private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any; private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined; private readonly getCameraControls: () => FallbackCameraControls | undefined;
@@ -55,6 +58,7 @@ export class TwoDMode {
constructor(options: TwoDModeOptions) { constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks; this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget; this.fullscreenTarget = options.fullscreenTarget;
this.mediaCapabilities = options.mediaCapabilities;
this.getActiveContentMesh = options.getActiveContentMesh; this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera; this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls; this.getCameraControls = options.getCameraControls;
@@ -82,6 +86,7 @@ export class TwoDMode {
this.callbacks.seekToProgress(progress); this.callbacks.seekToProgress(progress);
} }
}, },
mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget, fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active, getIsActive: () => this.active,
playerContainer: this.playerContainer, playerContainer: this.playerContainer,
@@ -120,7 +125,9 @@ export class TwoDMode {
this.callbacks.showActiveContentMesh(); this.callbacks.showActiveContentMesh();
} }
this.callbacks.togglePlayPause(); if (this.mediaCapabilities.playback) {
this.callbacks.togglePlayPause();
}
this.addEventListeners(canvas); this.addEventListeners(canvas);
this.controls.show(); this.controls.show();
this.positionControls(); this.positionControls();
@@ -172,6 +179,7 @@ export class TwoDMode {
updateTimeline(): void { updateTimeline(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.timeline) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -181,6 +189,7 @@ export class TwoDMode {
updatePlaybackButton(): void { updatePlaybackButton(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -190,6 +199,7 @@ export class TwoDMode {
updateMuteButton(): void { updateMuteButton(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.audio) return;
const video = this.getVideo(); const video = this.getVideo();
if (video) { if (video) {
@@ -199,6 +209,7 @@ export class TwoDMode {
handleVideoEnd(): void { handleVideoEnd(): void {
if (!this.active) return; if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
this.controls.showPersistent(); this.controls.showPersistent();
this.updatePlaybackButton(); this.updatePlaybackButton();

View File

@@ -98,3 +98,20 @@ export function createVideoTexture(video: HTMLVideoElement) {
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
return texture; return texture;
} }
export function createImageTexture(image: HTMLImageElement) {
const texture = new THREE.Texture(image);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return texture;
}
export function createMediaTexture(source: HTMLImageElement | HTMLVideoElement) {
if (source.tagName.toLowerCase() === 'img') {
return createImageTexture(source as HTMLImageElement);
}
return createVideoTexture(source as HTMLVideoElement);
}

View File

@@ -1,6 +1,7 @@
declare module 'https://unpkg.com/three/build/three.module.js' { declare module 'https://unpkg.com/three/build/three.module.js' {
export const Matrix4: any; export const Matrix4: any;
export const CanvasTexture: any; export const CanvasTexture: any;
export const Texture: any;
export const VideoTexture: any; export const VideoTexture: any;
export const LinearFilter: any; export const LinearFilter: any;
export const SRGBColorSpace: any; export const SRGBColorSpace: any;

View File

@@ -11,7 +11,7 @@ import {
positionPlaneForPresentation as positionPlaneForPresentationCore, positionPlaneForPresentation as positionPlaneForPresentationCore,
showActiveContentMesh as showActiveContentMeshCore showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js'; } from './rendering/projection.js';
import { createVideoTexture as createVideoTextureCore } from './rendering/three-utils.js'; import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.js';
import { FallbackCameraControls } from './modes/fallback-camera-controls.js'; import { FallbackCameraControls } from './modes/fallback-camera-controls.js';
import { MediaController } from './media/media-controller.js'; import { MediaController } from './media/media-controller.js';
import { import {
@@ -49,7 +49,7 @@ let frameCounter = 0;
let isXrLoopActive = false; let isXrLoopActive = false;
let vrControlPanel; let vrControlPanel;
let mediaController: MediaController | undefined; let mediaController: MediaController | undefined;
let textureManager: MediaTextureManager<HTMLVideoElement> | undefined; let textureManager: MediaTextureManager<HTMLImageElement | HTMLVideoElement> | undefined;
let vrPanel: VrControlPanel | undefined; let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined; let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility(); const vrPanelVisibility = new VrPanelVisibility();
@@ -62,7 +62,7 @@ bootstrapPlayer(_playerBase, (context) => {
playerContainer = context.playerContainer; playerContainer = context.playerContainer;
projectionMode = context.projectionMode; projectionMode = context.projectionMode;
mediaAdapter = context.mediaAdapter; mediaAdapter = context.mediaAdapter;
video = mediaAdapter.element; video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
playBtn = context.playButton; playBtn = context.playButton;
init(); init();
}); });
@@ -84,9 +84,9 @@ function positionPlaneForPresentation(isFallback2D = false) {
positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE); positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE);
} }
function createVideoTexture() { function createMediaTexture() {
if (!textureManager) { if (!textureManager) {
throw new Error('Video texture manager is not initialized.'); throw new Error('Media texture manager is not initialized.');
} }
return textureManager.create(); return textureManager.create();
} }
@@ -118,7 +118,7 @@ function restoreVideoTextureAfterContextRestored() {
} }
function getMediaTitle() { function getMediaTitle() {
return mediaAdapter?.getTitle() || 'Video Title'; return mediaAdapter?.getTitle() || 'Media Title';
} }
@@ -136,18 +136,20 @@ function init() {
throw new Error('Media adapter is not initialized.'); throw new Error('Media adapter is not initialized.');
} }
video = mediaAdapter.element; video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
textureManager = new MediaTextureManager( textureManager = new MediaTextureManager(
mediaAdapter.textureSource, mediaAdapter.textureSource,
createVideoTextureCore, createMediaTextureCore,
() => mediaAdapter?.shouldUpdateTexture() ?? false () => mediaAdapter?.shouldUpdateTexture() ?? false
); );
mediaController = new MediaController({ mediaController = video
is2DModeActive, ? new MediaController({
on2DPlaybackResume: show2DControlPanel, is2DModeActive,
playButton: playBtn, on2DPlaybackResume: show2DControlPanel,
video playButton: playBtn,
}); video
})
: undefined;
const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => { const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
applySbsTextureWindow(renderer, activeCamera, material); applySbsTextureWindow(renderer, activeCamera, material);
}); });
@@ -165,7 +167,7 @@ function init() {
}); });
twoDMode = new TwoDMode({ twoDMode = new TwoDMode({
callbacks: { callbacks: {
createMediaTexture: createVideoTexture, createMediaTexture,
forward: () => mediaController?.forward(), forward: () => mediaController?.forward(),
positionPlaneForPresentation, positionPlaneForPresentation,
rewind: () => mediaController?.rewind(), rewind: () => mediaController?.rewind(),
@@ -175,6 +177,7 @@ function init() {
togglePlayPause: () => mediaController?.togglePlayPause() togglePlayPause: () => mediaController?.togglePlayPause()
}, },
fullscreenTarget: playerContainer, fullscreenTarget: playerContainer,
mediaCapabilities: mediaAdapter.capabilities,
getActiveContentMesh: () => activeContentMesh, getActiveContentMesh: () => activeContentMesh,
getCamera: () => camera2D, getCamera: () => camera2D,
getCameraControls: () => fallbackCameraControls, getCameraControls: () => fallbackCameraControls,
@@ -194,7 +197,7 @@ function init() {
} }
try { // Phase 2: VR Control Panel UI try { // Phase 2: VR Control Panel UI
vrPanel = createVrControlPanel(scene, getMediaTitle()); vrPanel = createVrControlPanel(scene, getMediaTitle(), mediaAdapter?.capabilities);
vrControlPanel = vrPanel.group; vrControlPanel = vrPanel.group;
vrPanelVisibility.setPanel(vrPanel); vrPanelVisibility.setPanel(vrPanel);
uiElements.push(...vrPanel.interactables); uiElements.push(...vrPanel.interactables);
@@ -386,8 +389,7 @@ async function handleEnterVRButtonClick() {
return; return;
} }
// Hide the play button after click hideEnterButton();
mediaController?.hidePlayButton();
// Check if VR is supported // Check if VR is supported
if (playBtn.dataset.xrSupported === "true") { if (playBtn.dataset.xrSupported === "true") {
@@ -448,7 +450,7 @@ async function actualSessionToggle() {
throw new Error("VR mesh components not ready for texture."); throw new Error("VR mesh components not ready for texture.");
} }
if (!textureManager) { if (!textureManager) {
throw new Error("Video texture manager is not initialized."); throw new Error("Media texture manager is not initialized.");
} }
textureManager.assignToMaterial(sphereMaterial); textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh(); showActiveContentMesh();
@@ -491,6 +493,15 @@ async function actualSessionToggle() {
} }
} }
function hideEnterButton() {
if (mediaController) {
mediaController.hidePlayButton();
return;
}
playBtn?.classList.add('hidden');
}
function onVRSessionEnd(event) { function onVRSessionEnd(event) {
const endedSession = event.session; const endedSession = event.session;

View File

@@ -1,6 +1,7 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js'; import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon } from '../dom/icons.js'; import { drawLucideIcon } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js'; import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type ButtonLayout = { type ButtonLayout = {
centerX: number; centerX: number;
@@ -12,21 +13,21 @@ type ButtonLayout = {
export type VrControlPanel = { export type VrControlPanel = {
exitButtonMesh: any; exitButtonMesh: any;
forwardButtonMesh: any; forwardButtonMesh?: any;
group: any; group: any;
interactables: any[]; interactables: any[];
playPauseButtonCanvas: HTMLCanvasElement; playPauseButtonCanvas?: HTMLCanvasElement;
playPauseButtonContext: CanvasRenderingContext2D | null; playPauseButtonContext?: CanvasRenderingContext2D | null;
playPauseButtonMesh: any; playPauseButtonMesh?: any;
playPauseButtonTexture: any; playPauseButtonTexture?: any;
rewindButtonMesh: any; rewindButtonMesh?: any;
seekBarHitAreaMesh: any; seekBarHitAreaMesh?: any;
seekBarProgressMesh: any; seekBarProgressMesh?: any;
seekBarTrackMesh: any; seekBarTrackMesh?: any;
volumeButtonCanvas: HTMLCanvasElement; volumeButtonCanvas?: HTMLCanvasElement;
volumeButtonContext: CanvasRenderingContext2D | null; volumeButtonContext?: CanvasRenderingContext2D | null;
volumeButtonMesh: any; volumeButtonMesh?: any;
volumeButtonTexture: any; volumeButtonTexture?: any;
}; };
const FIGMA_PANEL_WIDTH_PX = 450; const FIGMA_PANEL_WIDTH_PX = 450;
@@ -72,7 +73,18 @@ const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGH
const VR_BUTTON_TEXTURE_SIZE = 128; const VR_BUTTON_TEXTURE_SIZE = 128;
const VR_BUTTON_ICON_SIZE = 82; const VR_BUTTON_ICON_SIZE = 82;
export function createVrControlPanel(scene: any, title: string): VrControlPanel { const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
audio: true,
dynamicTexture: true,
playback: true,
timeline: true
};
export function createVrControlPanel(
scene: any,
title: string,
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
): VrControlPanel {
const group = new THREE.Group(); const group = new THREE.Group();
group.position.set(0, 0.5, -1.8); group.position.set(0, 0.5, -1.8);
group.rotation.x = 0; group.rotation.x = 0;
@@ -83,74 +95,87 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(panelMesh); group.add(panelMesh);
interactables.push(panelMesh); interactables.push(panelMesh);
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 }); let seekBarTrackMesh;
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT); let seekBarProgressMesh;
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial); let seekBarHitAreaMesh;
seekBarTrackMesh.name = 'seekBarTrackVisual'; if (mediaCapabilities.timeline) {
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET; const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
seekBarTrackMesh.position.z = 0.01; const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
seekBarTrackMesh.renderOrder = 1; seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
group.add(seekBarTrackMesh); seekBarTrackMesh.name = 'seekBarTrackVisual';
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarTrackMesh.position.z = 0.01;
seekBarTrackMesh.renderOrder = 1;
group.add(seekBarTrackMesh);
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 }); const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT); const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial); seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
seekBarProgressMesh.name = 'seekBarProgressVisual'; seekBarProgressMesh.name = 'seekBarProgressVisual';
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR; seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2; seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
seekBarProgressMesh.position.z = 0.015; seekBarProgressMesh.position.z = 0.015;
seekBarProgressMesh.scale.x = 0.001; seekBarProgressMesh.scale.x = 0.001;
seekBarProgressMesh.renderOrder = 2; seekBarProgressMesh.renderOrder = 2;
group.add(seekBarProgressMesh); group.add(seekBarProgressMesh);
const seekBarHitAreaGeometry = new THREE.PlaneGeometry( const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
); );
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 }); const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial); seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
seekBarHitAreaMesh.name = 'seekBarHitArea'; seekBarHitAreaMesh.name = 'seekBarHitArea';
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET; seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarHitAreaMesh.position.z = 0.012; seekBarHitAreaMesh.position.z = 0.012;
seekBarHitAreaMesh.renderOrder = 2; seekBarHitAreaMesh.renderOrder = 2;
group.add(seekBarHitAreaMesh); group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh); interactables.push(seekBarHitAreaMesh);
}
const playPauseButtonCanvas = document.createElement('canvas'); let playPauseButtonCanvas;
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE; let playPauseButtonContext;
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE; let playPauseButtonTexture;
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d'); let playPauseButtonMesh;
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas); let rewindButtonMesh;
playPauseButtonTexture.minFilter = THREE.LinearFilter; let forwardButtonMesh;
const playPauseButtonMesh = createButtonMesh({ if (mediaCapabilities.playback) {
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX, playPauseButtonCanvas = document.createElement('canvas');
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX, playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
name: 'vrPlayPauseButton', playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX, playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
texture: playPauseButtonTexture playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
}); playPauseButtonTexture.minFilter = THREE.LinearFilter;
group.add(playPauseButtonMesh); playPauseButtonMesh = createButtonMesh({
interactables.push(playPauseButtonMesh); centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
name: 'vrPlayPauseButton',
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
texture: playPauseButtonTexture
});
group.add(playPauseButtonMesh);
interactables.push(playPauseButtonMesh);
const rewindButtonMesh = createButtonMesh({ rewindButtonMesh = createButtonMesh({
centerX: FIGMA_REWIND_BUTTON_X_PX, centerX: FIGMA_REWIND_BUTTON_X_PX,
centerY: FIGMA_REWIND_BUTTON_Y_PX, centerY: FIGMA_REWIND_BUTTON_Y_PX,
name: 'vrRewindButton', name: 'vrRewindButton',
size: FIGMA_REWIND_BUTTON_SIZE_PX, size: FIGMA_REWIND_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15') texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
}); });
group.add(rewindButtonMesh); group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh); interactables.push(rewindButtonMesh);
const forwardButtonMesh = createButtonMesh({ forwardButtonMesh = createButtonMesh({
centerX: FIGMA_FORWARD_BUTTON_X_PX, centerX: FIGMA_FORWARD_BUTTON_X_PX,
centerY: FIGMA_FORWARD_BUTTON_Y_PX, centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton', name: 'vrForwardButton',
size: FIGMA_FORWARD_BUTTON_SIZE_PX, size: FIGMA_FORWARD_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15') texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
}); });
group.add(forwardButtonMesh); group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh); interactables.push(forwardButtonMesh);
}
const exitButtonMesh = createButtonMesh({ const exitButtonMesh = createButtonMesh({
centerX: FIGMA_EXIT_BUTTON_X_PX, centerX: FIGMA_EXIT_BUTTON_X_PX,
@@ -162,21 +187,27 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(exitButtonMesh); group.add(exitButtonMesh);
interactables.push(exitButtonMesh); interactables.push(exitButtonMesh);
const volumeButtonCanvas = document.createElement('canvas'); let volumeButtonCanvas;
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE; let volumeButtonContext;
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE; let volumeButtonTexture;
const volumeButtonContext = volumeButtonCanvas.getContext('2d'); let volumeButtonMesh;
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas); if (mediaCapabilities.audio) {
volumeButtonTexture.minFilter = THREE.LinearFilter; volumeButtonCanvas = document.createElement('canvas');
const volumeButtonMesh = createButtonMesh({ volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
centerX: FIGMA_VOLUME_BUTTON_X_PX, volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
centerY: FIGMA_VOLUME_BUTTON_Y_PX, volumeButtonContext = volumeButtonCanvas.getContext('2d');
name: 'vrVolumeButton', volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
size: FIGMA_VOLUME_BUTTON_SIZE_PX, volumeButtonTexture.minFilter = THREE.LinearFilter;
texture: volumeButtonTexture volumeButtonMesh = createButtonMesh({
}); centerX: FIGMA_VOLUME_BUTTON_X_PX,
group.add(volumeButtonMesh); centerY: FIGMA_VOLUME_BUTTON_Y_PX,
interactables.push(volumeButtonMesh); name: 'vrVolumeButton',
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
texture: volumeButtonTexture
});
group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh);
}
group.visible = false; group.visible = false;

28
test-3d-image.html Normal file
View File

@@ -0,0 +1,28 @@
<!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>3D Image Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">3D Image</h1>
<p class="demo-kicker">Projection: plane. Media: side-by-side image.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
<img src="media/169_3d_test.png" alt="Demo SBS image" title="3D Image Plane" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body>
</html>

30
test-3d-video.html Normal file
View File

@@ -0,0 +1,30 @@
<!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>3D Video Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">3D Video</h1>
<p class="demo-kicker">Projection: plane. Media: side-by-side video.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<div class="demo-player-frame" data-vr-web-player data-projection="plane">
<video poster="poster.jpg" title="3D Video Plane" crossorigin="anonymous" playsinline preload="metadata">
<source src="media/sbs-video.mp4" type="video/mp4">
</video>
</div>
</div>
</main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body>
</html>

28
test-vr180-3d-image.html Normal file
View File

@@ -0,0 +1,28 @@
<!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>VR180 3D Image Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR180 3D Image</h1>
<p class="demo-kicker">Projection: VR180. Media: side-by-side image.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
<img src="media/169_3d_test.png" alt="Demo VR180 SBS image" title="VR180 3D Image" crossorigin="anonymous">
</div>
</div>
</main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body>
</html>

30
test-vr180-3d-video.html Normal file
View File

@@ -0,0 +1,30 @@
<!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>VR180 3D Video Test</title>
<link rel="stylesheet" href="./demo.css">
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
</head>
<body>
<main class="demo-page">
<div class="demo-shell demo-player">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR180 3D Video</h1>
<p class="demo-kicker">Projection: VR180. Media: side-by-side video.</p>
</div>
<a class="demo-back" href="./index.html">Back</a>
</header>
<div class="demo-player-frame" data-vr-web-player data-projection="vr180">
<video poster="poster.jpg" title="VR180 3D Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="media/sbs-video.mp4" type="video/mp4">
</video>
</div>
</div>
</main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -2,9 +2,19 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
createMediaAdapter, createMediaAdapter,
ImageMediaAdapter,
VideoMediaAdapter VideoMediaAdapter
} from '../vr180player/media/media-adapter.js'; } from '../vr180player/media/media-adapter.js';
function createClassList() {
return {
values: [],
add(...values) {
this.values.push(...values);
}
};
}
function createVideo({ function createVideo({
ended = false, ended = false,
paused = false, paused = false,
@@ -12,16 +22,15 @@ function createVideo({
title = '' title = ''
} = {}) { } = {}) {
return { return {
classList: { HAVE_METADATA: 1,
values: [], classList: createClassList(),
add(value) {
this.values.push(value);
}
},
ended, ended,
loadCount: 0, loadCount: 0,
paused, paused,
readyState: 0,
style: { display: '' }, style: { display: '' },
tagName: 'VIDEO',
addEventListener() {},
getAttribute(name) { getAttribute(name) {
return name === 'title' ? title : ''; return name === 'title' ? title : '';
}, },
@@ -38,6 +47,31 @@ function createVideo({
}; };
} }
function createImage({
alt = '',
complete = true,
naturalWidth = 1920,
source = 'https://cdn.example.com/images/demo-image.png',
title = ''
} = {}) {
return {
alt,
classList: createClassList(),
complete,
currentSrc: source,
naturalWidth,
src: source,
style: { display: '' },
tagName: 'IMG',
addEventListener() {},
getAttribute(name) {
if (name === 'title') return title;
if (name === 'alt') return alt;
return '';
}
};
}
test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => { test('VideoMediaAdapter exposes video capabilities and lifecycle helpers', () => {
const video = createVideo({ title: 'Demo Title' }); const video = createVideo({ title: 'Demo Title' });
const adapter = new VideoMediaAdapter(video); const adapter = new VideoMediaAdapter(video);
@@ -77,11 +111,51 @@ test('VideoMediaAdapter falls back to source filename and tracks texture update
assert.equal(adapter.shouldUpdateTexture(), false); assert.equal(adapter.shouldUpdateTexture(), false);
}); });
test('ImageMediaAdapter exposes static image capabilities and lifecycle helpers', async () => {
const image = createImage({ alt: 'Alt Title' });
const adapter = new ImageMediaAdapter(image);
let readyCount = 0;
adapter.bindLoadState({
onError: () => {},
onReady: () => {
readyCount += 1;
}
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(adapter.capabilities, {
audio: false,
dynamicTexture: false,
playback: false,
timeline: false
});
assert.equal(adapter.element, image);
assert.equal(adapter.textureSource, image);
assert.equal(adapter.getTitle(), 'Alt Title');
assert.equal(adapter.shouldUpdateTexture(), false);
assert.equal(readyCount, 1);
adapter.hideElement();
assert.equal(image.style.display, 'none');
adapter.showElement();
assert.equal(image.style.display, '');
});
test('ImageMediaAdapter falls back to source filename', () => {
const image = createImage({ alt: '', source: 'https://cdn.example.com/media/static-sbs-demo.png' });
const adapter = new ImageMediaAdapter(image);
assert.equal(adapter.getTitle(), 'static sbs demo');
});
test('createMediaAdapter finds and marks the supported video element', () => { test('createMediaAdapter finds and marks the supported video element', () => {
const video = createVideo(); const video = createVideo();
const playerContainer = { const playerContainer = {
querySelector(selector) { querySelectorAll(selector) {
return selector === 'video' ? video : null; return selector === 'video,img' ? [video] : [];
} }
}; };
@@ -89,5 +163,28 @@ test('createMediaAdapter finds and marks the supported video element', () => {
assert.ok(adapter instanceof VideoMediaAdapter); assert.ok(adapter instanceof VideoMediaAdapter);
assert.equal(adapter.element, video); assert.equal(adapter.element, video);
assert.deepEqual(video.classList.values, ['vrwp-video']); assert.deepEqual(video.classList.values, ['vrwp-media', 'vrwp-video']);
});
test('createMediaAdapter finds and marks the supported image element', () => {
const image = createImage();
const playerContainer = {
querySelectorAll(selector) {
return selector === 'video,img' ? [image] : [];
}
};
const adapter = createMediaAdapter(playerContainer);
assert.ok(adapter instanceof ImageMediaAdapter);
assert.equal(adapter.element, image);
assert.deepEqual(image.classList.values, ['vrwp-media', 'vrwp-image']);
});
test('createMediaAdapter refuses missing or ambiguous media elements', () => {
const video = createVideo();
const image = createImage();
assert.equal(createMediaAdapter({ querySelectorAll: () => [] }), null);
assert.equal(createMediaAdapter({ querySelectorAll: () => [video, image] }), null);
}); });

View File

@@ -4,11 +4,13 @@
width: 100%; width: 100%;
} }
.vrwp-video, .vrwp-media,
.vrwp canvas { .vrwp canvas {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
display: block;
object-fit: contain;
} }
.vrwp-play-button { .vrwp-play-button {