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
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:
- `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
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>
```
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
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`.
```html
<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
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, `plane` maps the left and right halves onto the matching eyes of a floating 16:9 video plane.
- Outside WebXR, both modes render only the left half of the SBS video so viewers do not see the raw double image.
- 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 plane.
- 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.
## 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:

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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VR Web Player</title>
<link rel="stylesheet" href="./vr180player/vr180-player.css" data-vr-web-player-stylesheet>
<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>
<title>VR Web Player Test Pages</title>
<link rel="stylesheet" href="./demo.css">
</head>
<body>
<main>
<h1>VR Web Player</h1>
<p>This is a web-based player for side-by-side stereoscopic video.</p>
<div data-vr-web-player data-projection="vr180">
<video poster="poster.jpg" title="Demo Video" crossorigin="anonymous" playsinline preload="metadata">
<source src="sbs-video.mp4" type="video/mp4">
</video>
<main class="demo-page">
<div class="demo-shell">
<header class="demo-topbar">
<div>
<h1 class="demo-brand">VR Web Player Tests</h1>
<p class="demo-kicker">Open a focused page for each media and projection combination.</p>
</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>
</main>
<script type="module" src="./vr180player/vr180-player.js"></script>
</body>
</html>

View File

@@ -41,7 +41,7 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
const mediaAdapter = createMediaAdapter(playerContainer);
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;
}
@@ -49,6 +49,15 @@ export function bootstrapPlayer(playerBase: string, onReady: (context: Bootstrap
playerContainer.appendChild(playButton);
playerContainer.appendChild(create2DControlPanel());
playButton.disabled = true;
mediaAdapter.bindLoadState({
onError: (event) => {
console.error(`VR_WEB_PLAYER_MEDIA: Failed to load ${mediaAdapter.kind} media.`, event);
playButton.disabled = true;
},
onReady: () => {
playButton.disabled = false;
}
});
mediaAdapter.load();
completeXrSupportCheck(playButton, () => {

View File

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

View File

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

View File

@@ -5,11 +5,19 @@ export type MediaCapabilities = {
timeline: boolean;
};
type MediaLoadCallbacks = {
onError: (event: Event) => void;
onReady: () => void;
};
export type MediaKind = 'image' | 'video';
export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextureSource = TElement> {
readonly capabilities: MediaCapabilities;
readonly element: TElement;
readonly kind: string;
readonly kind: MediaKind;
readonly textureSource: TTextureSource;
bindLoadState(callbacks: MediaLoadCallbacks): void;
getTitle(): string;
hideElement(): void;
load(): void;
@@ -17,7 +25,7 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
showElement(): void;
}
export type SupportedMediaAdapter = VideoMediaAdapter;
export type SupportedMediaAdapter = ImageMediaAdapter | VideoMediaAdapter;
const VIDEO_CAPABILITIES: MediaCapabilities = {
audio: true,
@@ -26,9 +34,16 @@ const VIDEO_CAPABILITIES: MediaCapabilities = {
timeline: true
};
const IMAGE_CAPABILITIES: MediaCapabilities = {
audio: false,
dynamicTexture: false,
playback: false,
timeline: false
};
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
readonly capabilities = VIDEO_CAPABILITIES;
readonly kind = 'video';
readonly kind = 'video' as const;
constructor(readonly element: HTMLVideoElement) {}
@@ -42,6 +57,16 @@ export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVid
'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 {
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 {
const videoElement = playerContainer.querySelector<HTMLVideoElement>('video');
if (!videoElement) {
const mediaElements = Array.from(
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
);
if (mediaElements.length !== 1) {
return null;
}
videoElement.classList.add('vrwp-video');
return new VideoMediaAdapter(videoElement);
const mediaElement = mediaElements[0];
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
} from '../rendering/renderer-lifecycle.js';
import { TwoDControlPanel } from '../dom/two-d-control-panel.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type TwoDModeCallbacks = {
createMediaTexture: () => any;
@@ -21,6 +22,7 @@ type TwoDModeCallbacks = {
type TwoDModeOptions = {
callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement;
mediaCapabilities: MediaCapabilities;
getActiveContentMesh: () => any;
getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined;
@@ -40,6 +42,7 @@ export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement;
private readonly mediaCapabilities: MediaCapabilities;
private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined;
@@ -55,6 +58,7 @@ export class TwoDMode {
constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget;
this.mediaCapabilities = options.mediaCapabilities;
this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls;
@@ -82,6 +86,7 @@ export class TwoDMode {
this.callbacks.seekToProgress(progress);
}
},
mediaCapabilities: this.mediaCapabilities,
fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active,
playerContainer: this.playerContainer,
@@ -120,7 +125,9 @@ export class TwoDMode {
this.callbacks.showActiveContentMesh();
}
this.callbacks.togglePlayPause();
if (this.mediaCapabilities.playback) {
this.callbacks.togglePlayPause();
}
this.addEventListeners(canvas);
this.controls.show();
this.positionControls();
@@ -172,6 +179,7 @@ export class TwoDMode {
updateTimeline(): void {
if (!this.active) return;
if (!this.mediaCapabilities.timeline) return;
const video = this.getVideo();
if (video) {
@@ -181,6 +189,7 @@ export class TwoDMode {
updatePlaybackButton(): void {
if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
const video = this.getVideo();
if (video) {
@@ -190,6 +199,7 @@ export class TwoDMode {
updateMuteButton(): void {
if (!this.active) return;
if (!this.mediaCapabilities.audio) return;
const video = this.getVideo();
if (video) {
@@ -199,6 +209,7 @@ export class TwoDMode {
handleVideoEnd(): void {
if (!this.active) return;
if (!this.mediaCapabilities.playback) return;
this.controls.showPersistent();
this.updatePlaybackButton();

View File

@@ -98,3 +98,20 @@ export function createVideoTexture(video: HTMLVideoElement) {
texture.colorSpace = THREE.SRGBColorSpace;
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' {
export const Matrix4: any;
export const CanvasTexture: any;
export const Texture: any;
export const VideoTexture: any;
export const LinearFilter: any;
export const SRGBColorSpace: any;

View File

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

View File

@@ -1,6 +1,7 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
import { drawLucideIcon } from '../dom/icons.js';
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
import type { MediaCapabilities } from '../media/media-adapter.js';
type ButtonLayout = {
centerX: number;
@@ -12,21 +13,21 @@ type ButtonLayout = {
export type VrControlPanel = {
exitButtonMesh: any;
forwardButtonMesh: any;
forwardButtonMesh?: any;
group: any;
interactables: any[];
playPauseButtonCanvas: HTMLCanvasElement;
playPauseButtonContext: CanvasRenderingContext2D | null;
playPauseButtonMesh: any;
playPauseButtonTexture: any;
rewindButtonMesh: any;
seekBarHitAreaMesh: any;
seekBarProgressMesh: any;
seekBarTrackMesh: any;
volumeButtonCanvas: HTMLCanvasElement;
volumeButtonContext: CanvasRenderingContext2D | null;
volumeButtonMesh: any;
volumeButtonTexture: any;
playPauseButtonCanvas?: HTMLCanvasElement;
playPauseButtonContext?: CanvasRenderingContext2D | null;
playPauseButtonMesh?: any;
playPauseButtonTexture?: any;
rewindButtonMesh?: any;
seekBarHitAreaMesh?: any;
seekBarProgressMesh?: any;
seekBarTrackMesh?: any;
volumeButtonCanvas?: HTMLCanvasElement;
volumeButtonContext?: CanvasRenderingContext2D | null;
volumeButtonMesh?: any;
volumeButtonTexture?: any;
};
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_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();
group.position.set(0, 0.5, -1.8);
group.rotation.x = 0;
@@ -83,74 +95,87 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(panelMesh);
interactables.push(panelMesh);
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
seekBarTrackMesh.name = 'seekBarTrackVisual';
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarTrackMesh.position.z = 0.01;
seekBarTrackMesh.renderOrder = 1;
group.add(seekBarTrackMesh);
let seekBarTrackMesh;
let seekBarProgressMesh;
let seekBarHitAreaMesh;
if (mediaCapabilities.timeline) {
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
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 seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
seekBarProgressMesh.name = 'seekBarProgressVisual';
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
seekBarProgressMesh.position.z = 0.015;
seekBarProgressMesh.scale.x = 0.001;
seekBarProgressMesh.renderOrder = 2;
group.add(seekBarProgressMesh);
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);
seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
seekBarProgressMesh.name = 'seekBarProgressVisual';
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
seekBarProgressMesh.position.z = 0.015;
seekBarProgressMesh.scale.x = 0.001;
seekBarProgressMesh.renderOrder = 2;
group.add(seekBarProgressMesh);
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
);
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
seekBarHitAreaMesh.name = 'seekBarHitArea';
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarHitAreaMesh.position.z = 0.012;
seekBarHitAreaMesh.renderOrder = 2;
group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh);
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
WORLD_SEEK_BAR_WIDTH,
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
);
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
seekBarHitAreaMesh.name = 'seekBarHitArea';
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
seekBarHitAreaMesh.position.z = 0.012;
seekBarHitAreaMesh.renderOrder = 2;
group.add(seekBarHitAreaMesh);
interactables.push(seekBarHitAreaMesh);
}
const playPauseButtonCanvas = document.createElement('canvas');
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
playPauseButtonTexture.minFilter = THREE.LinearFilter;
const playPauseButtonMesh = createButtonMesh({
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);
let playPauseButtonCanvas;
let playPauseButtonContext;
let playPauseButtonTexture;
let playPauseButtonMesh;
let rewindButtonMesh;
let forwardButtonMesh;
if (mediaCapabilities.playback) {
playPauseButtonCanvas = document.createElement('canvas');
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
playPauseButtonTexture.minFilter = THREE.LinearFilter;
playPauseButtonMesh = createButtonMesh({
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({
centerX: FIGMA_REWIND_BUTTON_X_PX,
centerY: FIGMA_REWIND_BUTTON_Y_PX,
name: 'vrRewindButton',
size: FIGMA_REWIND_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh);
rewindButtonMesh = createButtonMesh({
centerX: FIGMA_REWIND_BUTTON_X_PX,
centerY: FIGMA_REWIND_BUTTON_Y_PX,
name: 'vrRewindButton',
size: FIGMA_REWIND_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(rewindButtonMesh);
interactables.push(rewindButtonMesh);
const forwardButtonMesh = createButtonMesh({
centerX: FIGMA_FORWARD_BUTTON_X_PX,
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton',
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh);
forwardButtonMesh = createButtonMesh({
centerX: FIGMA_FORWARD_BUTTON_X_PX,
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
name: 'vrForwardButton',
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
});
group.add(forwardButtonMesh);
interactables.push(forwardButtonMesh);
}
const exitButtonMesh = createButtonMesh({
centerX: FIGMA_EXIT_BUTTON_X_PX,
@@ -162,21 +187,27 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
group.add(exitButtonMesh);
interactables.push(exitButtonMesh);
const volumeButtonCanvas = document.createElement('canvas');
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
const volumeButtonContext = volumeButtonCanvas.getContext('2d');
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
volumeButtonTexture.minFilter = THREE.LinearFilter;
const volumeButtonMesh = createButtonMesh({
centerX: FIGMA_VOLUME_BUTTON_X_PX,
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
name: 'vrVolumeButton',
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
texture: volumeButtonTexture
});
group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh);
let volumeButtonCanvas;
let volumeButtonContext;
let volumeButtonTexture;
let volumeButtonMesh;
if (mediaCapabilities.audio) {
volumeButtonCanvas = document.createElement('canvas');
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
volumeButtonContext = volumeButtonCanvas.getContext('2d');
volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
volumeButtonTexture.minFilter = THREE.LinearFilter;
volumeButtonMesh = createButtonMesh({
centerX: FIGMA_VOLUME_BUTTON_X_PX,
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
name: 'vrVolumeButton',
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
texture: volumeButtonTexture
});
group.add(volumeButtonMesh);
interactables.push(volumeButtonMesh);
}
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 {
createMediaAdapter,
ImageMediaAdapter,
VideoMediaAdapter
} from '../vr180player/media/media-adapter.js';
function createClassList() {
return {
values: [],
add(...values) {
this.values.push(...values);
}
};
}
function createVideo({
ended = false,
paused = false,
@@ -12,16 +22,15 @@ function createVideo({
title = ''
} = {}) {
return {
classList: {
values: [],
add(value) {
this.values.push(value);
}
},
HAVE_METADATA: 1,
classList: createClassList(),
ended,
loadCount: 0,
paused,
readyState: 0,
style: { display: '' },
tagName: 'VIDEO',
addEventListener() {},
getAttribute(name) {
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', () => {
const video = createVideo({ title: 'Demo Title' });
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);
});
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', () => {
const video = createVideo();
const playerContainer = {
querySelector(selector) {
return selector === 'video' ? video : null;
querySelectorAll(selector) {
return selector === 'video,img' ? [video] : [];
}
};
@@ -89,5 +163,28 @@ test('createMediaAdapter finds and marks the supported video element', () => {
assert.ok(adapter instanceof VideoMediaAdapter);
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%;
}
.vrwp-video,
.vrwp-media,
.vrwp canvas {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
display: block;
object-fit: contain;
}
.vrwp-play-button {