forked from EXT/VR180-Web-Player
Compare commits
2 Commits
24a166046e
...
8402fcd640
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8402fcd640 | ||
|
|
030a8b724b |
34
README.md
34
README.md
@@ -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
139
demo.css
Normal 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;
|
||||
}
|
||||
}
|
||||
57
index.html
57
index.html
@@ -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>
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,11 +151,26 @@ 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();
|
||||
});
|
||||
|
||||
if (mediaCapabilities.playback) {
|
||||
playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => {
|
||||
this.callbacks.onRewind();
|
||||
this.show();
|
||||
@@ -163,12 +185,16 @@ export class TwoDControlPanel {
|
||||
this.callbacks.onForward();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaCapabilities.audio) {
|
||||
this.muteButton?.addEventListener('click', () => {
|
||||
this.callbacks.onMute();
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaCapabilities.timeline) {
|
||||
this.progressBar?.addEventListener('click', (event) => {
|
||||
const rect = this.progressBar?.getBoundingClientRect();
|
||||
if (rect && rect.width > 0) {
|
||||
@@ -177,6 +203,7 @@ export class TwoDControlPanel {
|
||||
this.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private clearHideTimeout(): void {
|
||||
if (this.hideTimeout !== undefined) {
|
||||
|
||||
@@ -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, ' ') || '';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1
src/vr180player/types.d.ts
vendored
1
src/vr180player/types.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
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;
|
||||
|
||||
|
||||
@@ -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,9 +95,13 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
group.add(panelMesh);
|
||||
interactables.push(panelMesh);
|
||||
|
||||
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);
|
||||
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh.name = 'seekBarTrackVisual';
|
||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarTrackMesh.position.z = 0.01;
|
||||
@@ -94,7 +110,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
|
||||
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 = 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;
|
||||
@@ -108,21 +124,29 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
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 = 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');
|
||||
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;
|
||||
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
|
||||
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
|
||||
playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
|
||||
playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
|
||||
playPauseButtonTexture.minFilter = THREE.LinearFilter;
|
||||
const playPauseButtonMesh = createButtonMesh({
|
||||
playPauseButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
|
||||
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
|
||||
name: 'vrPlayPauseButton',
|
||||
@@ -132,7 +156,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
const rewindButtonMesh = createButtonMesh({
|
||||
rewindButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_REWIND_BUTTON_X_PX,
|
||||
centerY: FIGMA_REWIND_BUTTON_Y_PX,
|
||||
name: 'vrRewindButton',
|
||||
@@ -142,7 +166,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
|
||||
const forwardButtonMesh = createButtonMesh({
|
||||
forwardButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_FORWARD_BUTTON_X_PX,
|
||||
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
|
||||
name: 'vrForwardButton',
|
||||
@@ -151,6 +175,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
}
|
||||
|
||||
const exitButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_EXIT_BUTTON_X_PX,
|
||||
@@ -162,13 +187,18 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
group.add(exitButtonMesh);
|
||||
interactables.push(exitButtonMesh);
|
||||
|
||||
const volumeButtonCanvas = document.createElement('canvas');
|
||||
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;
|
||||
const volumeButtonContext = volumeButtonCanvas.getContext('2d');
|
||||
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
|
||||
volumeButtonContext = volumeButtonCanvas.getContext('2d');
|
||||
volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
|
||||
volumeButtonTexture.minFilter = THREE.LinearFilter;
|
||||
const volumeButtonMesh = createButtonMesh({
|
||||
volumeButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_VOLUME_BUTTON_X_PX,
|
||||
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
|
||||
name: 'vrVolumeButton',
|
||||
@@ -177,6 +207,7 @@ export function createVrControlPanel(scene: any, title: string): VrControlPanel
|
||||
});
|
||||
group.add(volumeButtonMesh);
|
||||
interactables.push(volumeButtonMesh);
|
||||
}
|
||||
|
||||
group.visible = false;
|
||||
|
||||
|
||||
28
test-3d-image.html
Normal file
28
test-3d-image.html
Normal 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
30
test-3d-video.html
Normal 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
28
test-vr180-3d-image.html
Normal 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
30
test-vr180-3d-video.html
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user