1
0

additions and refactors
All checks were successful
Test / test (push) Successful in 9m30s

This commit is contained in:
Aiden
2026-06-11 05:27:20 +10:00
parent ea184ba448
commit a470d4bdc7
18 changed files with 1623 additions and 1228 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/
# Generated by `npm run build`.
vr180player/*.css
vr180player/*.js
vr180player/**/*.js
/media

View File

@@ -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
View 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);
}));

View 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);
}
}

View 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 = '';
}
}

View File

@@ -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, ' ') || '';
}

View File

@@ -0,0 +1,3 @@
export function getFilenameTitle(source: string): string {
return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || '';
}

View 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 = '';
}
}

View 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

View 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
};
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}

View 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);
}

View 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>;

View 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
View 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);
});