forked from EXT/VR180-Web-Player
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Generated by `npm run build`.
|
# Generated by `npm run build`.
|
||||||
|
vr180player/*.css
|
||||||
vr180player/*.js
|
vr180player/*.js
|
||||||
vr180player/**/*.js
|
vr180player/**/*.js
|
||||||
/media
|
/media
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build && vite --host 0.0.0.0",
|
"dev": "npm run build && vite --host 0.0.0.0",
|
||||||
"build": "tsc",
|
"build": "tsc && node scripts/copy-styles.mjs",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs",
|
"test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs",
|
||||||
"preview": "npm run build && vite preview --host 127.0.0.1"
|
"preview": "npm run build && vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
16
scripts/copy-styles.mjs
Normal file
16
scripts/copy-styles.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { copyFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const styleCopies = [
|
||||||
|
{
|
||||||
|
from: join(rootDir, 'src', 'vr180player', 'styles', 'vr180-player.css'),
|
||||||
|
to: join(rootDir, 'vr180player', 'vr180-player.css')
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(styleCopies.map(async ({ from, to }) => {
|
||||||
|
await mkdir(dirname(to), { recursive: true });
|
||||||
|
await copyFile(from, to);
|
||||||
|
}));
|
||||||
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
113
src/vr180player/media/image-carousel-media-adapter.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type {
|
||||||
|
MediaAdapter,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaLoadCallbacks
|
||||||
|
} from './media-adapter.js';
|
||||||
|
import { getFilenameTitle } from './media-title.js';
|
||||||
|
|
||||||
|
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
||||||
|
audio: false,
|
||||||
|
carousel: true,
|
||||||
|
dynamicTexture: false,
|
||||||
|
navigation: true,
|
||||||
|
playback: false,
|
||||||
|
timeline: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
||||||
|
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
||||||
|
readonly kind = 'image' as const;
|
||||||
|
private currentIndex = 0;
|
||||||
|
private isHidden = false;
|
||||||
|
|
||||||
|
constructor(private readonly images: HTMLImageElement[]) {
|
||||||
|
this.images.forEach((image) => {
|
||||||
|
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
||||||
|
});
|
||||||
|
this.applyVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
get element(): HTMLImageElement {
|
||||||
|
return this.images[this.currentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ${this.currentIndex + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
||||||
|
let hasReportedReady = false;
|
||||||
|
const reportReadyIfAllLoaded = () => {
|
||||||
|
if (hasReportedReady || !this.areAllImagesReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReportedReady = true;
|
||||||
|
onReady();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.images.forEach((image) => {
|
||||||
|
image.addEventListener('load', reportReadyIfAllLoaded);
|
||||||
|
image.addEventListener('error', onError);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.areAllImagesReady()) {
|
||||||
|
queueMicrotask(reportReadyIfAllLoaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideElement(): void {
|
||||||
|
this.isHidden = true;
|
||||||
|
this.applyVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.images.forEach((image) => {
|
||||||
|
image.loading = 'eager';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): boolean {
|
||||||
|
return this.selectRelative(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): boolean {
|
||||||
|
return this.selectRelative(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUpdateTexture(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showElement(): void {
|
||||||
|
this.isHidden = false;
|
||||||
|
this.applyVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRelative(offset: number): boolean {
|
||||||
|
if (this.images.length <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
||||||
|
this.applyVisibility();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyVisibility(): void {
|
||||||
|
this.images.forEach((image, index) => {
|
||||||
|
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private areAllImagesReady(): boolean {
|
||||||
|
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/vr180player/media/image-media-adapter.ts
Normal file
66
src/vr180player/media/image-media-adapter.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type {
|
||||||
|
MediaAdapter,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaLoadCallbacks
|
||||||
|
} from './media-adapter.js';
|
||||||
|
import { getFilenameTitle } from './media-title.js';
|
||||||
|
|
||||||
|
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
||||||
|
audio: false,
|
||||||
|
carousel: false,
|
||||||
|
dynamicTexture: false,
|
||||||
|
navigation: false,
|
||||||
|
playback: false,
|
||||||
|
timeline: false
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUpdateTexture(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
showElement(): void {
|
||||||
|
this.element.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ImageCarouselMediaAdapter } from './image-carousel-media-adapter.js';
|
||||||
|
import { ImageMediaAdapter } from './image-media-adapter.js';
|
||||||
|
import { VideoMediaAdapter } from './video-media-adapter.js';
|
||||||
|
|
||||||
export type MediaCapabilities = {
|
export type MediaCapabilities = {
|
||||||
audio: boolean;
|
audio: boolean;
|
||||||
carousel: boolean;
|
carousel: boolean;
|
||||||
@@ -7,7 +11,7 @@ export type MediaCapabilities = {
|
|||||||
timeline: boolean;
|
timeline: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaLoadCallbacks = {
|
export type MediaLoadCallbacks = {
|
||||||
onError: (event: Event) => void;
|
onError: (event: Event) => void;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
};
|
};
|
||||||
@@ -31,233 +35,12 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
|
|||||||
|
|
||||||
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
||||||
|
|
||||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
export {
|
||||||
audio: true,
|
ImageCarouselMediaAdapter,
|
||||||
carousel: false,
|
ImageMediaAdapter,
|
||||||
dynamicTexture: true,
|
VideoMediaAdapter
|
||||||
navigation: true,
|
|
||||||
playback: true,
|
|
||||||
timeline: true
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMAGE_CAPABILITIES: MediaCapabilities = {
|
|
||||||
audio: false,
|
|
||||||
carousel: false,
|
|
||||||
dynamicTexture: false,
|
|
||||||
navigation: false,
|
|
||||||
playback: false,
|
|
||||||
timeline: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMAGE_CAROUSEL_CAPABILITIES: MediaCapabilities = {
|
|
||||||
audio: false,
|
|
||||||
carousel: true,
|
|
||||||
dynamicTexture: false,
|
|
||||||
navigation: true,
|
|
||||||
playback: false,
|
|
||||||
timeline: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
|
|
||||||
readonly capabilities = VIDEO_CAPABILITIES;
|
|
||||||
readonly kind = 'video' as const;
|
|
||||||
|
|
||||||
constructor(readonly element: HTMLVideoElement) {}
|
|
||||||
|
|
||||||
get textureSource(): HTMLVideoElement {
|
|
||||||
return this.element;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitle(): string {
|
|
||||||
return this.element.getAttribute('title') ||
|
|
||||||
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
|
||||||
'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';
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.element.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
next(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
previous(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldUpdateTexture(): boolean {
|
|
||||||
return !this.element.paused && !this.element.ended;
|
|
||||||
}
|
|
||||||
|
|
||||||
showElement(): void {
|
|
||||||
this.element.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
|
|
||||||
next(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
previous(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldUpdateTexture(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
showElement(): void {
|
|
||||||
this.element.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImageCarouselMediaAdapter implements MediaAdapter<HTMLImageElement, HTMLImageElement> {
|
|
||||||
readonly capabilities = IMAGE_CAROUSEL_CAPABILITIES;
|
|
||||||
readonly kind = 'image' as const;
|
|
||||||
private currentIndex = 0;
|
|
||||||
private isHidden = false;
|
|
||||||
|
|
||||||
constructor(private readonly images: HTMLImageElement[]) {
|
|
||||||
this.images.forEach((image) => {
|
|
||||||
image.classList.add('vrwp-media', 'vrwp-image', 'vrwp-carousel-image');
|
|
||||||
});
|
|
||||||
this.applyVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
get element(): HTMLImageElement {
|
|
||||||
return this.images[this.currentIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ${this.currentIndex + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
bindLoadState({ onError, onReady }: MediaLoadCallbacks): void {
|
|
||||||
let hasReportedReady = false;
|
|
||||||
const reportReadyIfAllLoaded = () => {
|
|
||||||
if (hasReportedReady || !this.areAllImagesReady()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasReportedReady = true;
|
|
||||||
onReady();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.images.forEach((image) => {
|
|
||||||
image.addEventListener('load', reportReadyIfAllLoaded);
|
|
||||||
image.addEventListener('error', onError);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.areAllImagesReady()) {
|
|
||||||
queueMicrotask(reportReadyIfAllLoaded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideElement(): void {
|
|
||||||
this.isHidden = true;
|
|
||||||
this.applyVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.images.forEach((image) => {
|
|
||||||
image.loading = 'eager';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next(): boolean {
|
|
||||||
return this.selectRelative(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
previous(): boolean {
|
|
||||||
return this.selectRelative(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldUpdateTexture(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
showElement(): void {
|
|
||||||
this.isHidden = false;
|
|
||||||
this.applyVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectRelative(offset: number): boolean {
|
|
||||||
if (this.images.length <= 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentIndex = (this.currentIndex + offset + this.images.length) % this.images.length;
|
|
||||||
this.applyVisibility();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyVisibility(): void {
|
|
||||||
this.images.forEach((image, index) => {
|
|
||||||
image.style.display = this.isHidden || index !== this.currentIndex ? 'none' : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private areAllImagesReady(): boolean {
|
|
||||||
return this.images.every((image) => image.complete && image.naturalWidth > 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
|
export function createMediaAdapter(playerContainer: HTMLElement): SupportedMediaAdapter | null {
|
||||||
const mediaElements = Array.from(
|
const mediaElements = Array.from(
|
||||||
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
||||||
@@ -299,7 +82,3 @@ function isCarouselEnabled(playerContainer: HTMLElement): boolean {
|
|||||||
const carouselValue = playerContainer.dataset?.carousel;
|
const carouselValue = playerContainer.dataset?.carousel;
|
||||||
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
return carouselValue !== undefined && carouselValue.toLowerCase() !== 'false';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilenameTitle(source: string): string {
|
|
||||||
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
|
||||||
}
|
|
||||||
|
|||||||
3
src/vr180player/media/media-title.ts
Normal file
3
src/vr180player/media/media-title.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function getFilenameTitle(source: string): string {
|
||||||
|
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
|
||||||
|
}
|
||||||
65
src/vr180player/media/video-media-adapter.ts
Normal file
65
src/vr180player/media/video-media-adapter.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type {
|
||||||
|
MediaAdapter,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaLoadCallbacks
|
||||||
|
} from './media-adapter.js';
|
||||||
|
|
||||||
|
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||||
|
audio: true,
|
||||||
|
carousel: false,
|
||||||
|
dynamicTexture: true,
|
||||||
|
navigation: true,
|
||||||
|
playback: true,
|
||||||
|
timeline: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export class VideoMediaAdapter implements MediaAdapter<HTMLVideoElement, HTMLVideoElement> {
|
||||||
|
readonly capabilities = VIDEO_CAPABILITIES;
|
||||||
|
readonly kind = 'video' as const;
|
||||||
|
|
||||||
|
constructor(readonly element: HTMLVideoElement) {}
|
||||||
|
|
||||||
|
get textureSource(): HTMLVideoElement {
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTitle(): string {
|
||||||
|
return this.element.getAttribute('title') ||
|
||||||
|
this.element.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||||
|
'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';
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.element.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
next(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
previous(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUpdateTexture(): boolean {
|
||||||
|
return !this.element.paused && !this.element.ended;
|
||||||
|
}
|
||||||
|
|
||||||
|
showElement(): void {
|
||||||
|
this.element.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/vr180player/styles/vr180-player.css
Normal file
207
src/vr180player/styles/vr180-player.css
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
.vrwp {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp [hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-media,
|
||||||
|
.vrwp canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-play-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
transition: opacity 0.3s ease, transform 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-play-button:hover {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-play-button:active {
|
||||||
|
transform: translate(-50%, -50%) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-play-button.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-play-button .vrwp-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.vrwp-play-button {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.vrwp-play-button {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 80%;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(0, 0, 0, 0.70);
|
||||||
|
color: #fff;
|
||||||
|
font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-panel.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-status {
|
||||||
|
margin: 0 12px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-video-title {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-current-time,
|
||||||
|
.vrwp-total-time {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-progress {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content 1fr min-content;
|
||||||
|
grid-gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-played {
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #fff;
|
||||||
|
height: 4px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "full lflex nav rflex loop mute";
|
||||||
|
grid-template-columns: 44px 1fr 156px 1fr 44px 44px;
|
||||||
|
column-gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-panel button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-panel button:hover {
|
||||||
|
color: #d8d8d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-fullscreen,
|
||||||
|
.vrwp-loop,
|
||||||
|
.vrwp-mute,
|
||||||
|
.vrwp-back,
|
||||||
|
.vrwp-play-toggle,
|
||||||
|
.vrwp-forward {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-fullscreen {
|
||||||
|
grid-area: full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-mute {
|
||||||
|
grid-area: mute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-loop {
|
||||||
|
grid-area: loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-loop.active {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-nav {
|
||||||
|
grid-area: nav;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 44px 44px;
|
||||||
|
grid-gap: 12px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vrwp-skip-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -48%);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
90
src/vr180player/xr/hand-aim-three.ts
Normal file
90
src/vr180player/xr/hand-aim-three.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||||
|
import {
|
||||||
|
computePalmAimRay,
|
||||||
|
type PalmAimRay,
|
||||||
|
type VectorLike
|
||||||
|
} from './hand-aim.js';
|
||||||
|
|
||||||
|
export type AimRay = {
|
||||||
|
direction: any;
|
||||||
|
origin: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
||||||
|
|
||||||
|
export function getHandAimRay(hand: any): AimRay | null {
|
||||||
|
const joints = hand?.joints;
|
||||||
|
if (!joints) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const palmAimRay = computePalmAimRay({
|
||||||
|
handedness: getHandedness(hand),
|
||||||
|
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
|
||||||
|
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
|
||||||
|
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
|
||||||
|
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
|
||||||
|
wrist: getJointWorldPosition(joints.wrist)
|
||||||
|
});
|
||||||
|
if (!palmAimRay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAimRay(palmAimRay);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPalmAimRay(ray: AimRay): PalmAimRay {
|
||||||
|
return {
|
||||||
|
direction: fromThreeVector(ray.direction),
|
||||||
|
origin: fromThreeVector(ray.origin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAimRay(ray: PalmAimRay): AimRay {
|
||||||
|
return {
|
||||||
|
direction: toThreeVector(ray.direction),
|
||||||
|
origin: toThreeVector(ray.origin)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberHandedness(hand: any, event: any): void {
|
||||||
|
const handedness = event?.data?.handedness ||
|
||||||
|
event?.data?.inputSource?.handedness ||
|
||||||
|
hand?.inputState?.handedness;
|
||||||
|
|
||||||
|
if (handedness !== 'left' && handedness !== 'right') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hand.userData = {
|
||||||
|
...hand.userData,
|
||||||
|
vrwpHandedness: handedness
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHandedness(hand: any): string | undefined {
|
||||||
|
return hand?.userData?.vrwpHandedness ||
|
||||||
|
hand?.inputState?.handedness ||
|
||||||
|
hand?.userData?.inputSource?.handedness;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJointWorldPosition(joint: any): VectorLike | null {
|
||||||
|
if (!joint?.getWorldPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
joint.updateMatrixWorld?.(true);
|
||||||
|
return joint.getWorldPosition(new THREE.Vector3());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toThreeVector(vector: VectorLike): any {
|
||||||
|
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromThreeVector(vector: any): VectorLike {
|
||||||
|
return {
|
||||||
|
x: vector.x,
|
||||||
|
y: vector.y,
|
||||||
|
z: vector.z
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,15 +5,20 @@ import {
|
|||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
import {
|
import {
|
||||||
beginPalmAimSelection,
|
beginPalmAimSelection,
|
||||||
computePalmAimRay,
|
|
||||||
createPalmAimLatch,
|
createPalmAimLatch,
|
||||||
endPalmAimSelection,
|
endPalmAimSelection,
|
||||||
getPalmAimSelectionRay,
|
getPalmAimSelectionRay,
|
||||||
recordStablePalmAimRay,
|
recordStablePalmAimRay,
|
||||||
type PalmAimLatch,
|
type PalmAimLatch
|
||||||
type PalmAimRay,
|
|
||||||
type VectorLike
|
|
||||||
} from './hand-aim.js';
|
} from './hand-aim.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_RAY_DIRECTION,
|
||||||
|
getHandAimRay,
|
||||||
|
rememberHandedness,
|
||||||
|
toAimRay,
|
||||||
|
toPalmAimRay,
|
||||||
|
type AimRay
|
||||||
|
} from './hand-aim-three.js';
|
||||||
import {
|
import {
|
||||||
rememberPointerInputMode,
|
rememberPointerInputMode,
|
||||||
shouldUseHandPointer,
|
shouldUseHandPointer,
|
||||||
@@ -40,11 +45,6 @@ export type VrInputRig = {
|
|||||||
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AimRay = {
|
|
||||||
direction: any;
|
|
||||||
origin: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActiveSeekDrag = {
|
type ActiveSeekDrag = {
|
||||||
inputSource: VrInputSource;
|
inputSource: VrInputSource;
|
||||||
onSeek: (progress: number) => void;
|
onSeek: (progress: number) => void;
|
||||||
@@ -68,7 +68,6 @@ type VrInputSource = {
|
|||||||
pointerInputMode: PointerInputMode;
|
pointerInputMode: PointerInputMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
|
||||||
const tempMatrix = new THREE.Matrix4();
|
const tempMatrix = new THREE.Matrix4();
|
||||||
|
|
||||||
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
export function createVrInputRig(scene: any, renderer: any, onSelectStart: (event: any) => void): VrInputRig {
|
||||||
@@ -361,85 +360,6 @@ function updateHandPointerOverlays(handPointerOverlays: HandPointerOverlay[], ti
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHandAimRay(hand: any): AimRay | null {
|
|
||||||
const joints = hand?.joints;
|
|
||||||
if (!joints) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palmAimRay = computePalmAimRay({
|
|
||||||
handedness: getHandedness(hand),
|
|
||||||
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
|
|
||||||
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
|
|
||||||
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
|
|
||||||
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
|
|
||||||
wrist: getJointWorldPosition(joints.wrist)
|
|
||||||
});
|
|
||||||
if (!palmAimRay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = toThreeVector(palmAimRay.origin);
|
|
||||||
const direction = toThreeVector(palmAimRay.direction);
|
|
||||||
return { direction, origin };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPalmAimRay(ray: AimRay): PalmAimRay {
|
|
||||||
return {
|
|
||||||
direction: fromThreeVector(ray.direction),
|
|
||||||
origin: fromThreeVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAimRay(ray: PalmAimRay): AimRay {
|
|
||||||
return {
|
|
||||||
direction: toThreeVector(ray.direction),
|
|
||||||
origin: toThreeVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberHandedness(hand: any, event: any): void {
|
|
||||||
const handedness = event?.data?.handedness ||
|
|
||||||
event?.data?.inputSource?.handedness ||
|
|
||||||
hand?.inputState?.handedness;
|
|
||||||
|
|
||||||
if (handedness !== 'left' && handedness !== 'right') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hand.userData = {
|
|
||||||
...hand.userData,
|
|
||||||
vrwpHandedness: handedness
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHandedness(hand: any): string | undefined {
|
|
||||||
return hand?.userData?.vrwpHandedness ||
|
|
||||||
hand?.inputState?.handedness ||
|
|
||||||
hand?.userData?.inputSource?.handedness;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJointWorldPosition(joint: any): VectorLike | null {
|
|
||||||
if (!joint?.getWorldPosition) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
joint.updateMatrixWorld?.(true);
|
|
||||||
return joint.getWorldPosition(new THREE.Vector3());
|
|
||||||
}
|
|
||||||
|
|
||||||
function toThreeVector(vector: VectorLike): any {
|
|
||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromThreeVector(vector: any): VectorLike {
|
|
||||||
return {
|
|
||||||
x: vector.x,
|
|
||||||
y: vector.y,
|
|
||||||
z: vector.z
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventTimestamp(event: any): number {
|
function getEventTimestamp(event: any): number {
|
||||||
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
return Number.isFinite(event?.timeStamp) ? event.timeStamp : performance.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
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';
|
import type { MediaCapabilities } from '../media/media-adapter.js';
|
||||||
|
import {
|
||||||
type ButtonLayout = {
|
createStaticVrButtonTexture,
|
||||||
centerX: number;
|
createVrButtonTexture,
|
||||||
centerY: number;
|
updateLoopButtonTexture,
|
||||||
name: string;
|
updatePlayPauseButtonTexture,
|
||||||
size: number;
|
updateVolumeButtonTexture,
|
||||||
texture: any;
|
type VrButtonTexture
|
||||||
};
|
} from './vr-panel-button-textures.js';
|
||||||
|
import {
|
||||||
|
VR_PANEL_BUTTON_LAYOUTS,
|
||||||
|
WORLD_SEEK_BAR_WIDTH
|
||||||
|
} from './vr-panel-layout.js';
|
||||||
|
import {
|
||||||
|
createButtonMesh,
|
||||||
|
createPanelBackground,
|
||||||
|
createSeekBarMeshes,
|
||||||
|
createVrPanelGroup
|
||||||
|
} from './vr-panel-meshes.js';
|
||||||
|
|
||||||
export type VrControlPanel = {
|
export type VrControlPanel = {
|
||||||
exitButtonMesh: any;
|
exitButtonMesh: any;
|
||||||
@@ -34,53 +41,6 @@ export type VrControlPanel = {
|
|||||||
volumeButtonTexture?: any;
|
volumeButtonTexture?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIGMA_PANEL_WIDTH_PX = 450;
|
|
||||||
const FIGMA_PANEL_HEIGHT_PX = 132;
|
|
||||||
const FIGMA_CORNER_RADIUS_PX = 30;
|
|
||||||
const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
|
||||||
const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
|
||||||
const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
|
||||||
const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
|
||||||
const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
|
||||||
|
|
||||||
const FIGMA_PLAYPAUSE_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_PLAYPAUSE_BUTTON_X_PX = 225;
|
|
||||||
const FIGMA_PLAYPAUSE_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const FIGMA_REWIND_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_REWIND_BUTTON_X_PX = 169;
|
|
||||||
const FIGMA_REWIND_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_FORWARD_BUTTON_X_PX = 281;
|
|
||||||
const FIGMA_FORWARD_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const FIGMA_LOOP_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_LOOP_BUTTON_X_PX = 352;
|
|
||||||
const FIGMA_LOOP_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_EXIT_BUTTON_X_PX = 42;
|
|
||||||
const FIGMA_EXIT_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const FIGMA_VOLUME_BUTTON_SIZE_PX = 44;
|
|
||||||
const FIGMA_VOLUME_BUTTON_X_PX = 408;
|
|
||||||
const FIGMA_VOLUME_BUTTON_Y_PX = 90;
|
|
||||||
|
|
||||||
const WORLD_PANEL_WIDTH = 1.5;
|
|
||||||
const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
|
||||||
const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
|
||||||
const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
|
||||||
const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
|
||||||
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
|
||||||
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
|
||||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
|
||||||
|
|
||||||
const PANEL_TEXTURE_WIDTH = 1024;
|
|
||||||
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
|
||||||
const VR_BUTTON_TEXTURE_SIZE = 128;
|
|
||||||
const VR_BUTTON_ICON_SIZE = 82;
|
|
||||||
|
|
||||||
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
|
const DEFAULT_MEDIA_CAPABILITIES: MediaCapabilities = {
|
||||||
audio: true,
|
audio: true,
|
||||||
carousel: false,
|
carousel: false,
|
||||||
@@ -95,12 +55,9 @@ export function createVrControlPanel(
|
|||||||
title: string,
|
title: string,
|
||||||
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
|
mediaCapabilities: MediaCapabilities = DEFAULT_MEDIA_CAPABILITIES
|
||||||
): VrControlPanel {
|
): VrControlPanel {
|
||||||
const group = new THREE.Group();
|
const group = createVrPanelGroup(scene);
|
||||||
group.position.set(0, 0.5, -1.8);
|
|
||||||
group.rotation.x = 0;
|
|
||||||
scene.add(group);
|
|
||||||
|
|
||||||
const interactables: any[] = [];
|
const interactables: any[] = [];
|
||||||
|
|
||||||
const panelMesh = createPanelBackground(title);
|
const panelMesh = createPanelBackground(title);
|
||||||
group.add(panelMesh);
|
group.add(panelMesh);
|
||||||
interactables.push(panelMesh);
|
interactables.push(panelMesh);
|
||||||
@@ -109,139 +66,75 @@ export function createVrControlPanel(
|
|||||||
let seekBarProgressMesh;
|
let seekBarProgressMesh;
|
||||||
let seekBarHitAreaMesh;
|
let seekBarHitAreaMesh;
|
||||||
if (mediaCapabilities.timeline) {
|
if (mediaCapabilities.timeline) {
|
||||||
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
const seekBarMeshes = createSeekBarMeshes();
|
||||||
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
seekBarTrackMesh = seekBarMeshes.trackMesh;
|
||||||
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
seekBarProgressMesh = seekBarMeshes.progressMesh;
|
||||||
seekBarTrackMesh.name = 'seekBarTrackVisual';
|
seekBarHitAreaMesh = seekBarMeshes.hitAreaMesh;
|
||||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
|
||||||
seekBarTrackMesh.position.z = 0.01;
|
|
||||||
seekBarTrackMesh.renderOrder = 1;
|
|
||||||
group.add(seekBarTrackMesh);
|
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);
|
|
||||||
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);
|
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 });
|
|
||||||
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);
|
group.add(seekBarHitAreaMesh);
|
||||||
interactables.push(seekBarHitAreaMesh);
|
interactables.push(seekBarHitAreaMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
let playPauseButtonCanvas;
|
let playPauseButton: VrButtonTexture | undefined;
|
||||||
let playPauseButtonContext;
|
|
||||||
let playPauseButtonTexture;
|
|
||||||
let playPauseButtonMesh;
|
let playPauseButtonMesh;
|
||||||
let loopButtonCanvas;
|
let loopButton: VrButtonTexture | undefined;
|
||||||
let loopButtonContext;
|
|
||||||
let loopButtonTexture;
|
|
||||||
let loopButtonMesh;
|
let loopButtonMesh;
|
||||||
let rewindButtonMesh;
|
let rewindButtonMesh;
|
||||||
let forwardButtonMesh;
|
let forwardButtonMesh;
|
||||||
if (mediaCapabilities.playback) {
|
if (mediaCapabilities.playback) {
|
||||||
playPauseButtonCanvas = document.createElement('canvas');
|
playPauseButton = createVrButtonTexture();
|
||||||
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({
|
playPauseButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.playPause,
|
||||||
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
|
texture: playPauseButton.texture
|
||||||
name: 'vrPlayPauseButton',
|
|
||||||
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
|
|
||||||
texture: playPauseButtonTexture
|
|
||||||
});
|
});
|
||||||
group.add(playPauseButtonMesh);
|
group.add(playPauseButtonMesh);
|
||||||
interactables.push(playPauseButtonMesh);
|
interactables.push(playPauseButtonMesh);
|
||||||
|
|
||||||
loopButtonCanvas = document.createElement('canvas');
|
loopButton = createVrButtonTexture();
|
||||||
loopButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
updateLoopButtonTexture(loopButton, false);
|
||||||
loopButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
|
||||||
loopButtonContext = loopButtonCanvas.getContext('2d');
|
|
||||||
loopButtonTexture = new THREE.CanvasTexture(loopButtonCanvas);
|
|
||||||
loopButtonTexture.minFilter = THREE.LinearFilter;
|
|
||||||
drawVrLoopButtonIcon(loopButtonContext, loopButtonCanvas, false);
|
|
||||||
loopButtonMesh = createButtonMesh({
|
loopButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_LOOP_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.loop,
|
||||||
centerY: FIGMA_LOOP_BUTTON_Y_PX,
|
texture: loopButton.texture
|
||||||
name: 'vrLoopButton',
|
|
||||||
size: FIGMA_LOOP_BUTTON_SIZE_PX,
|
|
||||||
texture: loopButtonTexture
|
|
||||||
});
|
});
|
||||||
group.add(loopButtonMesh);
|
group.add(loopButtonMesh);
|
||||||
interactables.push(loopButtonMesh);
|
interactables.push(loopButtonMesh);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaCapabilities.navigation) {
|
if (mediaCapabilities.navigation) {
|
||||||
rewindButtonMesh = createButtonMesh({
|
rewindButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_REWIND_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.rewind,
|
||||||
centerY: FIGMA_REWIND_BUTTON_Y_PX,
|
|
||||||
name: 'vrRewindButton',
|
|
||||||
size: FIGMA_REWIND_BUTTON_SIZE_PX,
|
|
||||||
texture: mediaCapabilities.carousel
|
texture: mediaCapabilities.carousel
|
||||||
? createLucideButtonTexture('chevron-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
? createStaticVrButtonTexture('chevron-left')
|
||||||
: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
: createStaticVrButtonTexture('rotate-ccw', '15')
|
||||||
});
|
});
|
||||||
group.add(rewindButtonMesh);
|
group.add(rewindButtonMesh);
|
||||||
interactables.push(rewindButtonMesh);
|
interactables.push(rewindButtonMesh);
|
||||||
|
|
||||||
forwardButtonMesh = createButtonMesh({
|
forwardButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_FORWARD_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.forward,
|
||||||
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
|
|
||||||
name: 'vrForwardButton',
|
|
||||||
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
|
|
||||||
texture: mediaCapabilities.carousel
|
texture: mediaCapabilities.carousel
|
||||||
? createLucideButtonTexture('chevron-right', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
? createStaticVrButtonTexture('chevron-right')
|
||||||
: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
: createStaticVrButtonTexture('rotate-cw', '15')
|
||||||
});
|
});
|
||||||
group.add(forwardButtonMesh);
|
group.add(forwardButtonMesh);
|
||||||
interactables.push(forwardButtonMesh);
|
interactables.push(forwardButtonMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitButtonMesh = createButtonMesh({
|
const exitButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_EXIT_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.exit,
|
||||||
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
texture: createStaticVrButtonTexture('arrow-left')
|
||||||
name: 'vrExitButton',
|
|
||||||
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
|
||||||
texture: createLucideButtonTexture('arrow-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
|
||||||
});
|
});
|
||||||
group.add(exitButtonMesh);
|
group.add(exitButtonMesh);
|
||||||
interactables.push(exitButtonMesh);
|
interactables.push(exitButtonMesh);
|
||||||
|
|
||||||
let volumeButtonCanvas;
|
let volumeButton: VrButtonTexture | undefined;
|
||||||
let volumeButtonContext;
|
|
||||||
let volumeButtonTexture;
|
|
||||||
let volumeButtonMesh;
|
let volumeButtonMesh;
|
||||||
if (mediaCapabilities.audio) {
|
if (mediaCapabilities.audio) {
|
||||||
volumeButtonCanvas = document.createElement('canvas');
|
volumeButton = createVrButtonTexture();
|
||||||
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({
|
volumeButtonMesh = createButtonMesh({
|
||||||
centerX: FIGMA_VOLUME_BUTTON_X_PX,
|
...VR_PANEL_BUTTON_LAYOUTS.volume,
|
||||||
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
|
texture: volumeButton.texture
|
||||||
name: 'vrVolumeButton',
|
|
||||||
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
|
|
||||||
texture: volumeButtonTexture
|
|
||||||
});
|
});
|
||||||
group.add(volumeButtonMesh);
|
group.add(volumeButtonMesh);
|
||||||
interactables.push(volumeButtonMesh);
|
interactables.push(volumeButtonMesh);
|
||||||
@@ -254,68 +147,47 @@ export function createVrControlPanel(
|
|||||||
forwardButtonMesh,
|
forwardButtonMesh,
|
||||||
group,
|
group,
|
||||||
interactables,
|
interactables,
|
||||||
loopButtonCanvas,
|
loopButtonCanvas: loopButton?.canvas,
|
||||||
loopButtonContext,
|
loopButtonContext: loopButton?.context,
|
||||||
loopButtonMesh,
|
loopButtonMesh,
|
||||||
loopButtonTexture,
|
loopButtonTexture: loopButton?.texture,
|
||||||
playPauseButtonCanvas,
|
playPauseButtonCanvas: playPauseButton?.canvas,
|
||||||
playPauseButtonContext,
|
playPauseButtonContext: playPauseButton?.context,
|
||||||
playPauseButtonMesh,
|
playPauseButtonMesh,
|
||||||
playPauseButtonTexture,
|
playPauseButtonTexture: playPauseButton?.texture,
|
||||||
rewindButtonMesh,
|
rewindButtonMesh,
|
||||||
seekBarHitAreaMesh,
|
seekBarHitAreaMesh,
|
||||||
seekBarProgressMesh,
|
seekBarProgressMesh,
|
||||||
seekBarTrackMesh,
|
seekBarTrackMesh,
|
||||||
volumeButtonCanvas,
|
volumeButtonCanvas: volumeButton?.canvas,
|
||||||
volumeButtonContext,
|
volumeButtonContext: volumeButton?.context,
|
||||||
volumeButtonMesh,
|
volumeButtonMesh,
|
||||||
volumeButtonTexture
|
volumeButtonTexture: volumeButton?.texture
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
||||||
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
|
updatePlayPauseButtonTexture({
|
||||||
|
canvas: panel?.playPauseButtonCanvas,
|
||||||
const ctx = panel.playPauseButtonContext;
|
context: panel?.playPauseButtonContext,
|
||||||
const canvas = panel.playPauseButtonCanvas;
|
texture: panel?.playPauseButtonTexture
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
}, isPausedOrEnded);
|
||||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
|
||||||
drawLucideIcon(ctx, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
|
||||||
panel.playPauseButtonTexture.needsUpdate = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
|
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
|
||||||
if (!panel?.loopButtonContext || !panel.loopButtonTexture) return;
|
updateLoopButtonTexture({
|
||||||
|
canvas: panel?.loopButtonCanvas,
|
||||||
drawVrLoopButtonIcon(panel.loopButtonContext, panel.loopButtonCanvas, isLooping);
|
context: panel?.loopButtonContext,
|
||||||
panel.loopButtonTexture.needsUpdate = true;
|
texture: panel?.loopButtonTexture
|
||||||
|
}, isLooping);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
||||||
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
|
updateVolumeButtonTexture({
|
||||||
|
canvas: panel?.volumeButtonCanvas,
|
||||||
const ctx = panel.volumeButtonContext;
|
context: panel?.volumeButtonContext,
|
||||||
const canvas = panel.volumeButtonCanvas;
|
texture: panel?.volumeButtonTexture
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
}, isMuted);
|
||||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
|
||||||
drawLucideIcon(ctx, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
|
||||||
panel.volumeButtonTexture.needsUpdate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawVrLoopButtonIcon(
|
|
||||||
ctx: CanvasRenderingContext2D | null | undefined,
|
|
||||||
canvas: HTMLCanvasElement | undefined,
|
|
||||||
isLooping: boolean
|
|
||||||
): void {
|
|
||||||
if (!ctx || !canvas) return;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
if (isLooping) {
|
|
||||||
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
|
||||||
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
|
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
|
||||||
@@ -356,63 +228,3 @@ export function getSeekProgressFromIntersection(panel: VrControlPanel | undefine
|
|||||||
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
|
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
|
||||||
return Math.max(0, Math.min(1, normalizedPosition));
|
return Math.max(0, Math.min(1, normalizedPosition));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPanelBackground(title: string): any {
|
|
||||||
const panelCanvas = document.createElement('canvas');
|
|
||||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
|
||||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
|
||||||
const panelCtx = panelCanvas.getContext('2d');
|
|
||||||
|
|
||||||
if (!panelCtx) {
|
|
||||||
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
|
||||||
}
|
|
||||||
|
|
||||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
|
||||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
|
||||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
|
||||||
|
|
||||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
|
||||||
panelCtx.fillStyle = '#ffffff';
|
|
||||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
|
||||||
panelCtx.textAlign = 'center';
|
|
||||||
panelCtx.textBaseline = 'top';
|
|
||||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
|
||||||
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
|
||||||
|
|
||||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
|
||||||
panelTexture.minFilter = THREE.LinearFilter;
|
|
||||||
panelTexture.needsUpdate = true;
|
|
||||||
|
|
||||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
|
||||||
map: panelTexture,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0,
|
|
||||||
depthWrite: false
|
|
||||||
});
|
|
||||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
|
||||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
|
||||||
panelMesh.name = 'vrControlPanelBackground';
|
|
||||||
panelMesh.renderOrder = 0;
|
|
||||||
|
|
||||||
return panelMesh;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonLayout): any {
|
|
||||||
const buttonWorldSize = size * SCALE_FACTOR;
|
|
||||||
const buttonMaterial = new THREE.MeshBasicMaterial({
|
|
||||||
map: texture,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0,
|
|
||||||
depthWrite: false
|
|
||||||
});
|
|
||||||
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
|
||||||
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
|
||||||
buttonMesh.name = name;
|
|
||||||
buttonMesh.renderOrder = 3;
|
|
||||||
|
|
||||||
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
|
||||||
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
|
||||||
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
|
||||||
|
|
||||||
return buttonMesh;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import {
|
|||||||
getSeekProgressFromIntersection,
|
getSeekProgressFromIntersection,
|
||||||
type VrControlPanel
|
type VrControlPanel
|
||||||
} from './vr-control-panel.js';
|
} from './vr-control-panel.js';
|
||||||
|
import { getPalmAimSelectionRay } from './hand-aim.js';
|
||||||
import {
|
import {
|
||||||
computePalmAimRay,
|
getHandAimRay,
|
||||||
getPalmAimSelectionRay,
|
toAimRay,
|
||||||
type PalmAimRay,
|
type AimRay
|
||||||
type VectorLike
|
} from './hand-aim-three.js';
|
||||||
} from './hand-aim.js';
|
|
||||||
import { shouldUseHandPointer } from './input-mode.js';
|
import { shouldUseHandPointer } from './input-mode.js';
|
||||||
|
|
||||||
type VrControllerSelectionOptions = {
|
type VrControllerSelectionOptions = {
|
||||||
@@ -28,11 +28,6 @@ type VrControllerSelectionOptions = {
|
|||||||
vrPanel: VrControlPanel | undefined;
|
vrPanel: VrControlPanel | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AimRay = {
|
|
||||||
direction: any;
|
|
||||||
origin: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempMatrix = new THREE.Matrix4();
|
const tempMatrix = new THREE.Matrix4();
|
||||||
|
|
||||||
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
|
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
|
||||||
@@ -130,52 +125,3 @@ function getSelectionHandAimRay(controller: any): AimRay | null {
|
|||||||
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
||||||
return palmAimRay ? toAimRay(palmAimRay) : null;
|
return palmAimRay ? toAimRay(palmAimRay) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHandAimRay(hand: any): AimRay | null {
|
|
||||||
const joints = hand?.joints;
|
|
||||||
if (!joints) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const palmAimRay = computePalmAimRay({
|
|
||||||
handedness: getHandedness(hand),
|
|
||||||
indexMetacarpal: getJointWorldPosition(joints['index-finger-metacarpal']),
|
|
||||||
middleMetacarpal: getJointWorldPosition(joints['middle-finger-metacarpal']),
|
|
||||||
pinkyMetacarpal: getJointWorldPosition(joints['pinky-finger-metacarpal']),
|
|
||||||
ringMetacarpal: getJointWorldPosition(joints['ring-finger-metacarpal']),
|
|
||||||
wrist: getJointWorldPosition(joints.wrist)
|
|
||||||
});
|
|
||||||
if (!palmAimRay) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = toThreeVector(palmAimRay.origin);
|
|
||||||
const direction = toThreeVector(palmAimRay.direction);
|
|
||||||
return { direction, origin };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toAimRay(ray: PalmAimRay): AimRay {
|
|
||||||
return {
|
|
||||||
direction: toThreeVector(ray.direction),
|
|
||||||
origin: toThreeVector(ray.origin)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHandedness(hand: any): string | undefined {
|
|
||||||
return hand?.userData?.vrwpHandedness ||
|
|
||||||
hand?.inputState?.handedness ||
|
|
||||||
hand?.userData?.inputSource?.handedness;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJointWorldPosition(joint: any): VectorLike | null {
|
|
||||||
if (!joint?.getWorldPosition) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
joint.updateMatrixWorld?.(true);
|
|
||||||
return joint.getWorldPosition(new THREE.Vector3());
|
|
||||||
}
|
|
||||||
|
|
||||||
function toThreeVector(vector: VectorLike): any {
|
|
||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
|
||||||
}
|
|
||||||
|
|||||||
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
123
src/vr180player/xr/vr-panel-button-textures.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||||
|
import { drawLucideIcon, type LucideIconName } from '../dom/icons.js';
|
||||||
|
import { createLucideButtonTexture, drawRoundedRect } from '../rendering/three-utils.js';
|
||||||
|
import {
|
||||||
|
FIGMA_CORNER_RADIUS_PX,
|
||||||
|
FIGMA_PANEL_HEIGHT_PX,
|
||||||
|
FIGMA_PANEL_WIDTH_PX,
|
||||||
|
FIGMA_TITLE_FONT_SIZE_PX,
|
||||||
|
FIGMA_TITLE_MARGIN_TOP_PX,
|
||||||
|
PANEL_TEXTURE_HEIGHT,
|
||||||
|
PANEL_TEXTURE_WIDTH,
|
||||||
|
VR_BUTTON_ICON_SIZE,
|
||||||
|
VR_BUTTON_TEXTURE_SIZE
|
||||||
|
} from './vr-panel-layout.js';
|
||||||
|
|
||||||
|
export type VrButtonTextureControls = {
|
||||||
|
canvas?: HTMLCanvasElement;
|
||||||
|
context?: CanvasRenderingContext2D | null;
|
||||||
|
texture?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VrButtonTexture = {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
context: CanvasRenderingContext2D | null;
|
||||||
|
texture: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createVrButtonTexture(): VrButtonTexture {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||||
|
canvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
const texture = new THREE.CanvasTexture(canvas);
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvas,
|
||||||
|
context,
|
||||||
|
texture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStaticVrButtonTexture(iconName: LucideIconName, label?: string): any {
|
||||||
|
return createLucideButtonTexture(iconName, '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPanelBackgroundTexture(title: string): any {
|
||||||
|
const panelCanvas = document.createElement('canvas');
|
||||||
|
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||||
|
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||||
|
const panelCtx = panelCanvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!panelCtx) {
|
||||||
|
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
||||||
|
}
|
||||||
|
|
||||||
|
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||||
|
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||||
|
|
||||||
|
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||||
|
panelCtx.fillStyle = '#ffffff';
|
||||||
|
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||||
|
panelCtx.textAlign = 'center';
|
||||||
|
panelCtx.textBaseline = 'top';
|
||||||
|
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||||
|
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||||
|
|
||||||
|
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||||
|
panelTexture.minFilter = THREE.LinearFilter;
|
||||||
|
panelTexture.needsUpdate = true;
|
||||||
|
return panelTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlayPauseButtonTexture(
|
||||||
|
controls: VrButtonTextureControls,
|
||||||
|
isPausedOrEnded: boolean
|
||||||
|
): void {
|
||||||
|
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||||
|
|
||||||
|
const { canvas, context, texture } = controls;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||||
|
drawLucideIcon(context, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLoopButtonTexture(
|
||||||
|
controls: VrButtonTextureControls,
|
||||||
|
isLooping: boolean
|
||||||
|
): void {
|
||||||
|
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||||
|
|
||||||
|
drawVrLoopButtonIcon(controls.context, controls.canvas, isLooping);
|
||||||
|
controls.texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVolumeButtonTexture(
|
||||||
|
controls: VrButtonTextureControls,
|
||||||
|
isMuted: boolean
|
||||||
|
): void {
|
||||||
|
if (!controls.context || !controls.canvas || !controls.texture) return;
|
||||||
|
|
||||||
|
const { canvas, context, texture } = controls;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||||
|
drawLucideIcon(context, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawVrLoopButtonIcon(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
isLooping: boolean
|
||||||
|
): void {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (isLooping) {
|
||||||
|
drawRoundedRect(ctx, 10, 10, canvas.width - 20, canvas.height - 20, 28, 'rgba(125, 211, 252, 0.24)', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||||
|
drawLucideIcon(ctx, 'repeat', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, isLooping ? '#7dd3fc' : '#ffffff', 2);
|
||||||
|
}
|
||||||
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
74
src/vr180player/xr/vr-panel-layout.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export type VrPanelButtonLayout = {
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FIGMA_PANEL_WIDTH_PX = 450;
|
||||||
|
export const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||||
|
export const FIGMA_CORNER_RADIUS_PX = 30;
|
||||||
|
export const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||||
|
export const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||||
|
export const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||||
|
export const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||||
|
export const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||||
|
|
||||||
|
export const WORLD_PANEL_WIDTH = 1.5;
|
||||||
|
export const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||||
|
export const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||||
|
export const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||||
|
export const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
||||||
|
export const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||||
|
export const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||||
|
export const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 12;
|
||||||
|
|
||||||
|
export const PANEL_TEXTURE_WIDTH = 1024;
|
||||||
|
export const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||||
|
export const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||||
|
export const VR_BUTTON_ICON_SIZE = 82;
|
||||||
|
|
||||||
|
export const VR_PANEL_POSITION = {
|
||||||
|
x: 0,
|
||||||
|
y: 0.5,
|
||||||
|
z: -1.8
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VR_PANEL_BUTTON_LAYOUTS = {
|
||||||
|
exit: {
|
||||||
|
centerX: 42,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrExitButton',
|
||||||
|
size: 44
|
||||||
|
},
|
||||||
|
forward: {
|
||||||
|
centerX: 281,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrForwardButton',
|
||||||
|
size: 44
|
||||||
|
},
|
||||||
|
loop: {
|
||||||
|
centerX: 352,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrLoopButton',
|
||||||
|
size: 44
|
||||||
|
},
|
||||||
|
playPause: {
|
||||||
|
centerX: 225,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrPlayPauseButton',
|
||||||
|
size: 44
|
||||||
|
},
|
||||||
|
rewind: {
|
||||||
|
centerX: 169,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrRewindButton',
|
||||||
|
size: 44
|
||||||
|
},
|
||||||
|
volume: {
|
||||||
|
centerX: 408,
|
||||||
|
centerY: 90,
|
||||||
|
name: 'vrVolumeButton',
|
||||||
|
size: 44
|
||||||
|
}
|
||||||
|
} satisfies Record<string, VrPanelButtonLayout>;
|
||||||
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
106
src/vr180player/xr/vr-panel-meshes.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||||
|
import {
|
||||||
|
FIGMA_PANEL_HEIGHT_PX,
|
||||||
|
FIGMA_PANEL_WIDTH_PX,
|
||||||
|
SCALE_FACTOR,
|
||||||
|
VR_PANEL_POSITION,
|
||||||
|
WORLD_PANEL_HEIGHT,
|
||||||
|
WORLD_PANEL_WIDTH,
|
||||||
|
WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER,
|
||||||
|
WORLD_SEEK_BAR_PROGRESS_HEIGHT,
|
||||||
|
WORLD_SEEK_BAR_TRACK_HEIGHT,
|
||||||
|
WORLD_SEEK_BAR_WIDTH,
|
||||||
|
WORLD_SEEK_BAR_Y_OFFSET,
|
||||||
|
type VrPanelButtonLayout
|
||||||
|
} from './vr-panel-layout.js';
|
||||||
|
import { createPanelBackgroundTexture } from './vr-panel-button-textures.js';
|
||||||
|
|
||||||
|
export type ButtonMeshOptions = VrPanelButtonLayout & {
|
||||||
|
texture: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeekBarMeshes = {
|
||||||
|
hitAreaMesh: any;
|
||||||
|
progressMesh: any;
|
||||||
|
trackMesh: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createVrPanelGroup(scene: any): any {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.position.set(VR_PANEL_POSITION.x, VR_PANEL_POSITION.y, VR_PANEL_POSITION.z);
|
||||||
|
group.rotation.x = 0;
|
||||||
|
scene.add(group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPanelBackground(title: string): any {
|
||||||
|
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
map: createPanelBackgroundTexture(title),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false
|
||||||
|
});
|
||||||
|
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||||
|
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||||
|
panelMesh.name = 'vrControlPanelBackground';
|
||||||
|
panelMesh.renderOrder = 0;
|
||||||
|
|
||||||
|
return panelMesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSeekBarMeshes(): SeekBarMeshes {
|
||||||
|
const trackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||||
|
const trackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||||
|
const trackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
|
||||||
|
trackMesh.name = 'seekBarTrackVisual';
|
||||||
|
trackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||||
|
trackMesh.position.z = 0.01;
|
||||||
|
trackMesh.renderOrder = 1;
|
||||||
|
|
||||||
|
const progressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||||
|
const progressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||||
|
const progressMesh = new THREE.Mesh(progressGeometry, progressMaterial);
|
||||||
|
progressMesh.name = 'seekBarProgressVisual';
|
||||||
|
progressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
|
||||||
|
progressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||||
|
progressMesh.position.z = 0.015;
|
||||||
|
progressMesh.scale.x = 0.001;
|
||||||
|
progressMesh.renderOrder = 2;
|
||||||
|
|
||||||
|
const hitAreaGeometry = new THREE.PlaneGeometry(
|
||||||
|
WORLD_SEEK_BAR_WIDTH,
|
||||||
|
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||||
|
);
|
||||||
|
const hitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||||
|
const hitAreaMesh = new THREE.Mesh(hitAreaGeometry, hitAreaMaterial);
|
||||||
|
hitAreaMesh.name = 'seekBarHitArea';
|
||||||
|
hitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||||
|
hitAreaMesh.position.z = 0.012;
|
||||||
|
hitAreaMesh.renderOrder = 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hitAreaMesh,
|
||||||
|
progressMesh,
|
||||||
|
trackMesh
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonMeshOptions): any {
|
||||||
|
const buttonWorldSize = size * SCALE_FACTOR;
|
||||||
|
const buttonMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
map: texture,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthWrite: false
|
||||||
|
});
|
||||||
|
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
||||||
|
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
||||||
|
buttonMesh.name = name;
|
||||||
|
buttonMesh.renderOrder = 3;
|
||||||
|
|
||||||
|
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||||
|
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||||
|
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
||||||
|
|
||||||
|
return buttonMesh;
|
||||||
|
}
|
||||||
71
tests/input-mode.test.mjs
Normal file
71
tests/input-mode.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPointerInputMode,
|
||||||
|
rememberPointerInputMode,
|
||||||
|
shouldUseHandPointer
|
||||||
|
} from '../vr180player/xr/input-mode.js';
|
||||||
|
|
||||||
|
test('getPointerInputMode detects WebXR hand sources', () => {
|
||||||
|
assert.equal(getPointerInputMode({ hand: {} }), 'hand');
|
||||||
|
assert.equal(getPointerInputMode({ profiles: ['generic-hand-tracking'] }), 'hand');
|
||||||
|
assert.equal(getPointerInputMode({ profiles: ['Oculus-Hand'] }), 'hand');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPointerInputMode detects controller sources', () => {
|
||||||
|
assert.equal(getPointerInputMode({ gamepad: {} }), 'controller');
|
||||||
|
assert.equal(getPointerInputMode({ targetRayMode: 'tracked-pointer' }), 'controller');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPointerInputMode returns null for unknown or gaze-like sources', () => {
|
||||||
|
assert.equal(getPointerInputMode(null), null);
|
||||||
|
assert.equal(getPointerInputMode(undefined), null);
|
||||||
|
assert.equal(getPointerInputMode({ profiles: ['generic-trigger'] }), null);
|
||||||
|
assert.equal(getPointerInputMode({ targetRayMode: 'gaze' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rememberPointerInputMode reads input sources from supported event shapes', () => {
|
||||||
|
const fromNestedInputSource = {};
|
||||||
|
rememberPointerInputMode(fromNestedInputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||||
|
assert.equal(fromNestedInputSource.pointerInputMode, 'hand');
|
||||||
|
|
||||||
|
const fromDirectInputSource = {};
|
||||||
|
rememberPointerInputMode(fromDirectInputSource, { inputSource: { gamepad: {} } }, 'hand');
|
||||||
|
assert.equal(fromDirectInputSource.pointerInputMode, 'controller');
|
||||||
|
|
||||||
|
const fromDataSource = {};
|
||||||
|
rememberPointerInputMode(fromDataSource, { data: { targetRayMode: 'tracked-pointer' } }, 'hand');
|
||||||
|
assert.equal(fromDataSource.pointerInputMode, 'controller');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rememberPointerInputMode keeps fallback mode when the event is ambiguous', () => {
|
||||||
|
const inputSource = { pointerInputMode: 'hand' };
|
||||||
|
|
||||||
|
rememberPointerInputMode(inputSource, { data: { targetRayMode: 'gaze' } }, 'controller');
|
||||||
|
|
||||||
|
assert.equal(inputSource.pointerInputMode, 'controller');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rememberPointerInputMode stores the input source on controller userData', () => {
|
||||||
|
const inputSource = {
|
||||||
|
controller: {
|
||||||
|
userData: {
|
||||||
|
existing: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rememberPointerInputMode(inputSource, { data: { inputSource: { hand: {} } } }, 'controller');
|
||||||
|
|
||||||
|
assert.equal(inputSource.pointerInputMode, 'hand');
|
||||||
|
assert.equal(inputSource.controller.userData.existing, true);
|
||||||
|
assert.equal(inputSource.controller.userData.vrwpInputSource, inputSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldUseHandPointer only enables the hand ray for remembered hand mode', () => {
|
||||||
|
assert.equal(shouldUseHandPointer({ pointerInputMode: 'hand' }), true);
|
||||||
|
assert.equal(shouldUseHandPointer({ pointerInputMode: 'controller' }), false);
|
||||||
|
assert.equal(shouldUseHandPointer({}), false);
|
||||||
|
assert.equal(shouldUseHandPointer(undefined), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user