forked from EXT/VR180-Web-Player
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
|
||||
# Generated by `npm run build`.
|
||||
vr180player/*.css
|
||||
vr180player/*.js
|
||||
vr180player/**/*.js
|
||||
/media
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && vite --host 0.0.0.0",
|
||||
"build": "tsc",
|
||||
"build": "tsc && node scripts/copy-styles.mjs",
|
||||
"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"
|
||||
},
|
||||
"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 = {
|
||||
audio: boolean;
|
||||
carousel: boolean;
|
||||
@@ -7,7 +11,7 @@ export type MediaCapabilities = {
|
||||
timeline: boolean;
|
||||
};
|
||||
|
||||
type MediaLoadCallbacks = {
|
||||
export type MediaLoadCallbacks = {
|
||||
onError: (event: Event) => void;
|
||||
onReady: () => void;
|
||||
};
|
||||
@@ -31,233 +35,12 @@ export interface MediaAdapter<TElement extends HTMLElement = HTMLElement, TTextu
|
||||
|
||||
export type SupportedMediaAdapter = ImageCarouselMediaAdapter | ImageMediaAdapter | VideoMediaAdapter;
|
||||
|
||||
const VIDEO_CAPABILITIES: MediaCapabilities = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
dynamicTexture: true,
|
||||
navigation: true,
|
||||
playback: true,
|
||||
timeline: true
|
||||
export {
|
||||
ImageCarouselMediaAdapter,
|
||||
ImageMediaAdapter,
|
||||
VideoMediaAdapter
|
||||
};
|
||||
|
||||
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 {
|
||||
const mediaElements = Array.from(
|
||||
playerContainer.querySelectorAll<HTMLVideoElement | HTMLImageElement>('video,img')
|
||||
@@ -299,7 +82,3 @@ function isCarouselEnabled(playerContainer: HTMLElement): boolean {
|
||||
const carouselValue = playerContainer.dataset?.carousel;
|
||||
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';
|
||||
import {
|
||||
beginPalmAimSelection,
|
||||
computePalmAimRay,
|
||||
createPalmAimLatch,
|
||||
endPalmAimSelection,
|
||||
getPalmAimSelectionRay,
|
||||
recordStablePalmAimRay,
|
||||
type PalmAimLatch,
|
||||
type PalmAimRay,
|
||||
type VectorLike
|
||||
type PalmAimLatch
|
||||
} from './hand-aim.js';
|
||||
import {
|
||||
DEFAULT_RAY_DIRECTION,
|
||||
getHandAimRay,
|
||||
rememberHandedness,
|
||||
toAimRay,
|
||||
toPalmAimRay,
|
||||
type AimRay
|
||||
} from './hand-aim-three.js';
|
||||
import {
|
||||
rememberPointerInputMode,
|
||||
shouldUseHandPointer,
|
||||
@@ -40,11 +45,6 @@ export type VrInputRig = {
|
||||
update: (timestamp: number, hoverTargets?: any[]) => boolean;
|
||||
};
|
||||
|
||||
type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
type ActiveSeekDrag = {
|
||||
inputSource: VrInputSource;
|
||||
onSeek: (progress: number) => void;
|
||||
@@ -68,7 +68,6 @@ type VrInputSource = {
|
||||
pointerInputMode: PointerInputMode;
|
||||
};
|
||||
|
||||
const DEFAULT_RAY_DIRECTION = new THREE.Vector3(0, 0, -1);
|
||||
const tempMatrix = new THREE.Matrix4();
|
||||
|
||||
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 {
|
||||
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';
|
||||
|
||||
type ButtonLayout = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
name: string;
|
||||
size: number;
|
||||
texture: any;
|
||||
};
|
||||
import {
|
||||
createStaticVrButtonTexture,
|
||||
createVrButtonTexture,
|
||||
updateLoopButtonTexture,
|
||||
updatePlayPauseButtonTexture,
|
||||
updateVolumeButtonTexture,
|
||||
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 = {
|
||||
exitButtonMesh: any;
|
||||
@@ -34,53 +41,6 @@ export type VrControlPanel = {
|
||||
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 = {
|
||||
audio: true,
|
||||
carousel: false,
|
||||
@@ -95,12 +55,9 @@ export function createVrControlPanel(
|
||||
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;
|
||||
scene.add(group);
|
||||
|
||||
const group = createVrPanelGroup(scene);
|
||||
const interactables: any[] = [];
|
||||
|
||||
const panelMesh = createPanelBackground(title);
|
||||
group.add(panelMesh);
|
||||
interactables.push(panelMesh);
|
||||
@@ -109,139 +66,75 @@ export function createVrControlPanel(
|
||||
let seekBarProgressMesh;
|
||||
let seekBarHitAreaMesh;
|
||||
if (mediaCapabilities.timeline) {
|
||||
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh.name = 'seekBarTrackVisual';
|
||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarTrackMesh.position.z = 0.01;
|
||||
seekBarTrackMesh.renderOrder = 1;
|
||||
const seekBarMeshes = createSeekBarMeshes();
|
||||
seekBarTrackMesh = seekBarMeshes.trackMesh;
|
||||
seekBarProgressMesh = seekBarMeshes.progressMesh;
|
||||
seekBarHitAreaMesh = seekBarMeshes.hitAreaMesh;
|
||||
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);
|
||||
|
||||
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||
);
|
||||
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
|
||||
seekBarHitAreaMesh.name = 'seekBarHitArea';
|
||||
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarHitAreaMesh.position.z = 0.012;
|
||||
seekBarHitAreaMesh.renderOrder = 2;
|
||||
group.add(seekBarHitAreaMesh);
|
||||
interactables.push(seekBarHitAreaMesh);
|
||||
}
|
||||
|
||||
let playPauseButtonCanvas;
|
||||
let playPauseButtonContext;
|
||||
let playPauseButtonTexture;
|
||||
let playPauseButton: VrButtonTexture | undefined;
|
||||
let playPauseButtonMesh;
|
||||
let loopButtonCanvas;
|
||||
let loopButtonContext;
|
||||
let loopButtonTexture;
|
||||
let loopButton: VrButtonTexture | undefined;
|
||||
let loopButtonMesh;
|
||||
let rewindButtonMesh;
|
||||
let forwardButtonMesh;
|
||||
if (mediaCapabilities.playback) {
|
||||
playPauseButtonCanvas = document.createElement('canvas');
|
||||
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
|
||||
playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
|
||||
playPauseButtonTexture.minFilter = THREE.LinearFilter;
|
||||
playPauseButton = createVrButtonTexture();
|
||||
playPauseButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
|
||||
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
|
||||
name: 'vrPlayPauseButton',
|
||||
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
|
||||
texture: playPauseButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.playPause,
|
||||
texture: playPauseButton.texture
|
||||
});
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
loopButtonCanvas = document.createElement('canvas');
|
||||
loopButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
loopButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
loopButtonContext = loopButtonCanvas.getContext('2d');
|
||||
loopButtonTexture = new THREE.CanvasTexture(loopButtonCanvas);
|
||||
loopButtonTexture.minFilter = THREE.LinearFilter;
|
||||
drawVrLoopButtonIcon(loopButtonContext, loopButtonCanvas, false);
|
||||
loopButton = createVrButtonTexture();
|
||||
updateLoopButtonTexture(loopButton, false);
|
||||
loopButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_LOOP_BUTTON_X_PX,
|
||||
centerY: FIGMA_LOOP_BUTTON_Y_PX,
|
||||
name: 'vrLoopButton',
|
||||
size: FIGMA_LOOP_BUTTON_SIZE_PX,
|
||||
texture: loopButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.loop,
|
||||
texture: loopButton.texture
|
||||
});
|
||||
group.add(loopButtonMesh);
|
||||
interactables.push(loopButtonMesh);
|
||||
|
||||
}
|
||||
|
||||
if (mediaCapabilities.navigation) {
|
||||
rewindButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_REWIND_BUTTON_X_PX,
|
||||
centerY: FIGMA_REWIND_BUTTON_Y_PX,
|
||||
name: 'vrRewindButton',
|
||||
size: FIGMA_REWIND_BUTTON_SIZE_PX,
|
||||
...VR_PANEL_BUTTON_LAYOUTS.rewind,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
? createStaticVrButtonTexture('chevron-left')
|
||||
: createStaticVrButtonTexture('rotate-ccw', '15')
|
||||
});
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
|
||||
forwardButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_FORWARD_BUTTON_X_PX,
|
||||
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
|
||||
name: 'vrForwardButton',
|
||||
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
|
||||
...VR_PANEL_BUTTON_LAYOUTS.forward,
|
||||
texture: mediaCapabilities.carousel
|
||||
? createLucideButtonTexture('chevron-right', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
? createStaticVrButtonTexture('chevron-right')
|
||||
: createStaticVrButtonTexture('rotate-cw', '15')
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
}
|
||||
|
||||
const exitButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_EXIT_BUTTON_X_PX,
|
||||
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
||||
name: 'vrExitButton',
|
||||
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
||||
texture: createLucideButtonTexture('arrow-left', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
...VR_PANEL_BUTTON_LAYOUTS.exit,
|
||||
texture: createStaticVrButtonTexture('arrow-left')
|
||||
});
|
||||
group.add(exitButtonMesh);
|
||||
interactables.push(exitButtonMesh);
|
||||
|
||||
let volumeButtonCanvas;
|
||||
let volumeButtonContext;
|
||||
let volumeButtonTexture;
|
||||
let volumeButton: VrButtonTexture | undefined;
|
||||
let volumeButtonMesh;
|
||||
if (mediaCapabilities.audio) {
|
||||
volumeButtonCanvas = document.createElement('canvas');
|
||||
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
volumeButtonContext = volumeButtonCanvas.getContext('2d');
|
||||
volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
|
||||
volumeButtonTexture.minFilter = THREE.LinearFilter;
|
||||
volumeButton = createVrButtonTexture();
|
||||
volumeButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_VOLUME_BUTTON_X_PX,
|
||||
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
|
||||
name: 'vrVolumeButton',
|
||||
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
|
||||
texture: volumeButtonTexture
|
||||
...VR_PANEL_BUTTON_LAYOUTS.volume,
|
||||
texture: volumeButton.texture
|
||||
});
|
||||
group.add(volumeButtonMesh);
|
||||
interactables.push(volumeButtonMesh);
|
||||
@@ -254,68 +147,47 @@ export function createVrControlPanel(
|
||||
forwardButtonMesh,
|
||||
group,
|
||||
interactables,
|
||||
loopButtonCanvas,
|
||||
loopButtonContext,
|
||||
loopButtonCanvas: loopButton?.canvas,
|
||||
loopButtonContext: loopButton?.context,
|
||||
loopButtonMesh,
|
||||
loopButtonTexture,
|
||||
playPauseButtonCanvas,
|
||||
playPauseButtonContext,
|
||||
loopButtonTexture: loopButton?.texture,
|
||||
playPauseButtonCanvas: playPauseButton?.canvas,
|
||||
playPauseButtonContext: playPauseButton?.context,
|
||||
playPauseButtonMesh,
|
||||
playPauseButtonTexture,
|
||||
playPauseButtonTexture: playPauseButton?.texture,
|
||||
rewindButtonMesh,
|
||||
seekBarHitAreaMesh,
|
||||
seekBarProgressMesh,
|
||||
seekBarTrackMesh,
|
||||
volumeButtonCanvas,
|
||||
volumeButtonContext,
|
||||
volumeButtonCanvas: volumeButton?.canvas,
|
||||
volumeButtonContext: volumeButton?.context,
|
||||
volumeButtonMesh,
|
||||
volumeButtonTexture
|
||||
volumeButtonTexture: volumeButton?.texture
|
||||
};
|
||||
}
|
||||
|
||||
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
||||
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
|
||||
|
||||
const ctx = panel.playPauseButtonContext;
|
||||
const canvas = panel.playPauseButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
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;
|
||||
updatePlayPauseButtonTexture({
|
||||
canvas: panel?.playPauseButtonCanvas,
|
||||
context: panel?.playPauseButtonContext,
|
||||
texture: panel?.playPauseButtonTexture
|
||||
}, isPausedOrEnded);
|
||||
}
|
||||
|
||||
export function updateVrLoopButtonIcon(panel: VrControlPanel | undefined, isLooping: boolean): void {
|
||||
if (!panel?.loopButtonContext || !panel.loopButtonTexture) return;
|
||||
|
||||
drawVrLoopButtonIcon(panel.loopButtonContext, panel.loopButtonCanvas, isLooping);
|
||||
panel.loopButtonTexture.needsUpdate = true;
|
||||
updateLoopButtonTexture({
|
||||
canvas: panel?.loopButtonCanvas,
|
||||
context: panel?.loopButtonContext,
|
||||
texture: panel?.loopButtonTexture
|
||||
}, isLooping);
|
||||
}
|
||||
|
||||
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
||||
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
|
||||
|
||||
const ctx = panel.volumeButtonContext;
|
||||
const canvas = panel.volumeButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
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);
|
||||
updateVolumeButtonTexture({
|
||||
canvas: panel?.volumeButtonCanvas,
|
||||
context: panel?.volumeButtonContext,
|
||||
texture: panel?.volumeButtonTexture
|
||||
}, isMuted);
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
type VrControlPanel
|
||||
} from './vr-control-panel.js';
|
||||
import { getPalmAimSelectionRay } from './hand-aim.js';
|
||||
import {
|
||||
computePalmAimRay,
|
||||
getPalmAimSelectionRay,
|
||||
type PalmAimRay,
|
||||
type VectorLike
|
||||
} from './hand-aim.js';
|
||||
getHandAimRay,
|
||||
toAimRay,
|
||||
type AimRay
|
||||
} from './hand-aim-three.js';
|
||||
import { shouldUseHandPointer } from './input-mode.js';
|
||||
|
||||
type VrControllerSelectionOptions = {
|
||||
@@ -28,11 +28,6 @@ type VrControllerSelectionOptions = {
|
||||
vrPanel: VrControlPanel | undefined;
|
||||
};
|
||||
|
||||
type AimRay = {
|
||||
direction: any;
|
||||
origin: any;
|
||||
};
|
||||
|
||||
const tempMatrix = new THREE.Matrix4();
|
||||
|
||||
export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void {
|
||||
@@ -130,52 +125,3 @@ function getSelectionHandAimRay(controller: any): AimRay | null {
|
||||
const palmAimRay = getPalmAimSelectionRay(latch, performance.now());
|
||||
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