diff --git a/.gitignore b/.gitignore index 7b25949..4f48824 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ # Generated by `npm run build`. +vr180player/*.css vr180player/*.js vr180player/**/*.js /media diff --git a/package.json b/package.json index 10e3108..dfb5a7d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/copy-styles.mjs b/scripts/copy-styles.mjs new file mode 100644 index 0000000..dc17dee --- /dev/null +++ b/scripts/copy-styles.mjs @@ -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); +})); diff --git a/src/vr180player/media/image-carousel-media-adapter.ts b/src/vr180player/media/image-carousel-media-adapter.ts new file mode 100644 index 0000000..875680f --- /dev/null +++ b/src/vr180player/media/image-carousel-media-adapter.ts @@ -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 { + 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); + } +} diff --git a/src/vr180player/media/image-media-adapter.ts b/src/vr180player/media/image-media-adapter.ts new file mode 100644 index 0000000..2c2fecf --- /dev/null +++ b/src/vr180player/media/image-media-adapter.ts @@ -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 { + 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 = ''; + } +} diff --git a/src/vr180player/media/media-adapter.ts b/src/vr180player/media/media-adapter.ts index 48e43ca..319cd9a 100644 --- a/src/vr180player/media/media-adapter.ts +++ b/src/vr180player/media/media-adapter.ts @@ -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 { - 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 { - 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 { - 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('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, ' ') || ''; -} diff --git a/src/vr180player/media/media-title.ts b/src/vr180player/media/media-title.ts new file mode 100644 index 0000000..436cdb4 --- /dev/null +++ b/src/vr180player/media/media-title.ts @@ -0,0 +1,3 @@ +export function getFilenameTitle(source: string): string { + return source.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || ''; +} diff --git a/src/vr180player/media/video-media-adapter.ts b/src/vr180player/media/video-media-adapter.ts new file mode 100644 index 0000000..8e23d02 --- /dev/null +++ b/src/vr180player/media/video-media-adapter.ts @@ -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 { + 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 = ''; + } +} diff --git a/src/vr180player/styles/vr180-player.css b/src/vr180player/styles/vr180-player.css new file mode 100644 index 0000000..ffd136c --- /dev/null +++ b/src/vr180player/styles/vr180-player.css @@ -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; +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index eb2ce56..98bbc31 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -4,7 +4,7 @@ import { type HeadLockMode, type ProjectionMode } from './config.js'; -import { bootstrapPlayer } from './bootstrap.js'; +import { bootstrapPlayer, type BootstrapContext } from './bootstrap.js'; import { createContentScene } from './rendering/content-scene.js'; import { applyHeadPositionLock as applyHeadPositionLockCore, @@ -38,645 +38,648 @@ import { import { MediaTextureManager } from './rendering/texture-manager.js'; import type { SupportedMediaAdapter } from './media/media-adapter.js'; -const _playerBase = new URL('.', import.meta.url).href; +export class PlayerSession { + private readonly headLockMode: HeadLockMode; + private readonly mediaAdapter: SupportedMediaAdapter; + private readonly playBtn: HTMLButtonElement; + private readonly playerContainer: HTMLElement; + private readonly projectionMode: ProjectionMode; + private readonly uiElements: any[] = []; + private readonly vrPanelVisibility = new VrPanelVisibility(); + private readonly handleVrSessionEnd = (event: any) => this.onVRSessionEnd(event); + private readonly renderXrFrame = (timestamp: number, frame: any) => this.renderXR(timestamp, frame); -let playerContainer, projectionMode: ProjectionMode, headLockMode: HeadLockMode; -let scene, camera, renderer, video, sphereMaterial; -let vr180Mesh, planeMesh, activeContentMesh; -let xrSession = null; -let raycaster, uiElements = []; -let xrInputRig; -let mediaAdapter: SupportedMediaAdapter | undefined; -let playBtn; -let frameCounter = 0; + private activeContentMesh: any; + private camera: any; + private camera2D: any; + private fallbackCameraControls: FallbackCameraControls | undefined; + private frameCounter = 0; + private isXrLoopActive = false; + private mediaController: MediaController | undefined; + private planeMesh: any; + private raycaster: any; + private renderer: any; + private scene: any; + private sphereMaterial: any; + private textureManager: MediaTextureManager | undefined; + private twoDMode: TwoDMode | undefined; + private video: HTMLVideoElement | undefined; + private vr180Mesh: any; + private vrControlPanel: any; + private vrPanel: VrControlPanel | undefined; + private xrInputRig: any; + private xrSession: any = null; -let isXrLoopActive = false; -let vrControlPanel; -let mediaController: MediaController | undefined; -let textureManager: MediaTextureManager | undefined; -let vrPanel: VrControlPanel | undefined; -let twoDMode: TwoDMode | undefined; -const vrPanelVisibility = new VrPanelVisibility(); - -// 2D Camera Controls -let camera2D; -let fallbackCameraControls: FallbackCameraControls | undefined; - -bootstrapPlayer(_playerBase, (context) => { - playerContainer = context.playerContainer; - projectionMode = context.projectionMode; - headLockMode = context.headLockMode; - mediaAdapter = context.mediaAdapter; - video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined; - playBtn = context.playButton; - init(); -}); - - -function applySbsTextureWindow(renderingRenderer, activeCamera, material) { - applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DModeActive()); -} - -function hideContentMeshes() { - hideContentMeshesCore(vr180Mesh, planeMesh); -} - -function showActiveContentMesh() { - showActiveContentMeshCore(vr180Mesh, planeMesh, activeContentMesh); -} - -function positionPlaneForPresentation(isFallback2D = false) { - positionPlaneForPresentationCore(planeMesh, camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE); -} - -function updateHeadPositionLock() { - if (!renderer?.xr?.isPresenting || !activeContentMesh) { - return; + constructor(context: BootstrapContext) { + this.playerContainer = context.playerContainer; + this.projectionMode = context.projectionMode; + this.headLockMode = context.headLockMode; + this.mediaAdapter = context.mediaAdapter; + this.video = context.mediaAdapter.kind === 'video' ? context.mediaAdapter.element : undefined; + this.playBtn = context.playButton; } - const xrCamera = renderer.xr.getCamera?.(camera) || camera; - applyHeadPositionLockCore( - activeContentMesh, - xrCamera, - projectionMode, - shouldLockContentToHeadPosition(headLockMode, projectionMode), - PLANE_DISTANCE - ); -} + init(): void { + try { + const playerRenderer = createPlayerRenderer(this.playerContainer, { + closeActiveXrSession: () => this.closeActiveXrSessionAfterContextLoss(), + hasActiveXrSession: () => !!this.xrSession, + restoreAfterContextRestored: () => this.restoreVideoTextureAfterContextRestored() + }); + this.scene = playerRenderer.scene; + this.camera = playerRenderer.camera; + this.renderer = playerRenderer.renderer; -function resetHeadPositionLock() { - resetHeadPositionLockedContentCore(vr180Mesh, planeMesh, PLANE_DISTANCE); -} + this.video = this.mediaAdapter.kind === 'video' ? this.mediaAdapter.element : undefined; + this.textureManager = new MediaTextureManager( + this.mediaAdapter.textureSource, + createMediaTextureCore, + () => this.mediaAdapter.shouldUpdateTexture() + ); + this.mediaController = this.video + ? new MediaController({ + is2DModeActive: () => this.is2DModeActive(), + on2DPlaybackResume: () => this.show2DControlPanel(), + playButton: this.playBtn, + video: this.video + }) + : undefined; -function createMediaTexture() { - if (!textureManager) { - throw new Error('Media texture manager is not initialized.'); - } - return textureManager.create(); -} + const contentScene = createContentScene(this.scene, this.projectionMode, (renderer, scene, activeCamera, geometry, material, group) => { + this.applySbsTextureWindow(renderer, activeCamera, material); + }); + this.sphereMaterial = contentScene.material; + this.vr180Mesh = contentScene.vr180Mesh; + this.planeMesh = contentScene.planeMesh; + this.activeContentMesh = contentScene.activeContentMesh; + this.uiElements.push(this.activeContentMesh); -function refreshMediaTexture() { - if (!mediaAdapter || !textureManager || !sphereMaterial) { - return; - } - - textureManager.setSource(mediaAdapter.textureSource); - textureManager.assignToMaterial(sphereMaterial); - - if (renderer?.xr?.isPresenting || twoDMode?.isActive) { - mediaAdapter.hideElement(); - } -} - -function navigateForward() { - if (mediaAdapter?.next?.()) { - refreshMediaTexture(); - return; - } - - mediaController?.forward(); - updateSeekBarAppearance(); -} - -function navigateBackward() { - if (mediaAdapter?.previous?.()) { - refreshMediaTexture(); - return; - } - - mediaController?.rewind(); - updateSeekBarAppearance(); -} - -function is2DModeActive() { - return twoDMode?.isActive ?? false; -} - -function closeActiveXrSessionAfterContextLoss() { - if (!xrSession) return; - - const sessionToClose = xrSession; - xrSession = null; - sessionToClose.removeEventListener('end', onVRSessionEnd); - sessionToClose.end().catch(e => { - console.error("Error ending session on context lost:", e); - }).finally(() => { - onVRSessionEnd({ session: sessionToClose }); - }); -} - -function restoreVideoTextureAfterContextRestored() { - if (mediaAdapter && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) { - textureManager?.assignToMaterial(sphereMaterial); - updateVRPlayPauseButtonIcon(); - updateVRVolumeButtonIcon(); - console.log("Re-initialized media texture after context restoration during VR."); - } -} - -function getMediaTitle() { - return mediaAdapter?.getTitle() || 'Media Title'; -} - - -function init() { - try { - const playerRenderer = createPlayerRenderer(playerContainer, { - closeActiveXrSession: closeActiveXrSessionAfterContextLoss, - hasActiveXrSession: () => !!xrSession, - restoreAfterContextRestored: restoreVideoTextureAfterContextRestored - }); - scene = playerRenderer.scene; - camera = playerRenderer.camera; - renderer = playerRenderer.renderer; - if (!mediaAdapter) { - throw new Error('Media adapter is not initialized.'); + this.camera2D = contentScene.fallbackCamera; + this.fallbackCameraControls = new FallbackCameraControls(this.camera2D, { + hideControls: () => this.hide2DControlPanel(), + isEnabled: () => this.is2DModeActive(), + showControls: () => this.show2DControlPanel() + }); + this.twoDMode = new TwoDMode({ + callbacks: { + createMediaTexture: () => this.createMediaTexture(), + forward: () => this.navigateForward(), + getIsLooping: () => this.mediaController?.isLooping() ?? false, + positionPlaneForPresentation: (isFallback2D) => this.positionPlaneForPresentation(isFallback2D), + rewind: () => this.navigateBackward(), + seekToProgress: (progress) => this.mediaController?.seekToProgress(progress), + showActiveContentMesh: () => this.showActiveContentMesh(), + toggleLoop: () => this.toggleLoop(), + toggleMute: () => this.mediaController?.toggleMute(), + togglePlayPause: () => this.mediaController?.togglePlayPause() + }, + fullscreenTarget: this.playerContainer, + mediaCapabilities: this.mediaAdapter.capabilities, + getActiveContentMesh: () => this.activeContentMesh, + getCamera: () => this.camera2D, + getCameraControls: () => this.fallbackCameraControls, + getMaterial: () => this.sphereMaterial, + getMediaElement: () => this.mediaAdapter.element, + getRenderer: () => this.renderer, + getScene: () => this.scene, + getVideo: () => this.video, + playerContainer: this.playerContainer, + projectionMode: this.projectionMode, + title: this.getMediaTitle() + }); + } catch (e) { + console.error('INIT_ERROR (Phase 1 - Core Setup):', e); + this.renderer = null; + return; } - video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined; - textureManager = new MediaTextureManager( - mediaAdapter.textureSource, - createMediaTextureCore, - () => mediaAdapter?.shouldUpdateTexture() ?? false + try { + this.vrPanel = createVrControlPanel(this.scene, this.getMediaTitle(), this.mediaAdapter.capabilities); + this.vrControlPanel = this.vrPanel.group; + this.vrPanelVisibility.setPanel(this.vrPanel); + this.uiElements.push(...this.vrPanel.interactables); + + this.xrInputRig = createVrInputRig(this.scene, this.renderer, (event) => this.onSelectStartVR(event)); + this.raycaster = this.xrInputRig.raycaster; + } catch (e) { + console.error('INIT_ERROR (Phase 2 - VR Controls Setup):', e); + } + + try { + this.playBtn.addEventListener('click', () => { + void this.handleEnterVRButtonClick(); + }); + window.addEventListener('resize', () => this.onWindowResize()); + + if (this.video) { + bindVideoEvents({ + onEnded: () => this.onVideoEnded(), + onPlaybackStateChange: () => { + this.updateVRPlayPauseButtonIcon(); + this.update2DPlayPauseButton(); + }, + onTimelineChange: () => { + this.updateSeekBarAppearance(); + this.update2DControlPanel(); + }, + onVolumeChange: () => { + this.updateVRVolumeButtonIcon(); + this.update2DMuteButton(); + }, + playButton: this.playBtn, + video: this.video + }); + } + } catch (e) { + console.error('INIT_ERROR (Phase 3 - Event Listeners):', e); + } + } + + private applySbsTextureWindow(renderingRenderer: any, activeCamera: any, material: any): void { + applySbsTextureWindowCore(renderingRenderer, activeCamera, material, this.is2DModeActive()); + } + + private hideContentMeshes(): void { + hideContentMeshesCore(this.vr180Mesh, this.planeMesh); + } + + private showActiveContentMesh(): void { + showActiveContentMeshCore(this.vr180Mesh, this.planeMesh, this.activeContentMesh); + } + + private positionPlaneForPresentation(isFallback2D = false): void { + positionPlaneForPresentationCore(this.planeMesh, this.camera2D, isFallback2D, PLANE_DISTANCE, PLANE_2D_DISTANCE); + } + + private updateHeadPositionLock(): void { + if (!this.renderer?.xr?.isPresenting || !this.activeContentMesh) { + return; + } + + const xrCamera = this.renderer.xr.getCamera?.(this.camera) || this.camera; + applyHeadPositionLockCore( + this.activeContentMesh, + xrCamera, + this.projectionMode, + shouldLockContentToHeadPosition(this.headLockMode, this.projectionMode), + PLANE_DISTANCE ); - mediaController = video - ? new MediaController({ - is2DModeActive, - on2DPlaybackResume: show2DControlPanel, - playButton: playBtn, - video - }) - : undefined; - const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => { - applySbsTextureWindow(renderer, activeCamera, material); - }); - sphereMaterial = contentScene.material; - vr180Mesh = contentScene.vr180Mesh; - planeMesh = contentScene.planeMesh; - activeContentMesh = contentScene.activeContentMesh; - uiElements.push(activeContentMesh); + } - camera2D = contentScene.fallbackCamera; - fallbackCameraControls = new FallbackCameraControls(camera2D, { - hideControls: hide2DControlPanel, - isEnabled: is2DModeActive, - showControls: show2DControlPanel + private resetHeadPositionLock(): void { + resetHeadPositionLockedContentCore(this.vr180Mesh, this.planeMesh, PLANE_DISTANCE); + } + + private createMediaTexture(): any { + if (!this.textureManager) { + throw new Error('Media texture manager is not initialized.'); + } + return this.textureManager.create(); + } + + private refreshMediaTexture(): void { + if (!this.textureManager || !this.sphereMaterial) { + return; + } + + this.textureManager.setSource(this.mediaAdapter.textureSource); + this.textureManager.assignToMaterial(this.sphereMaterial); + + if (this.renderer?.xr?.isPresenting || this.twoDMode?.isActive) { + this.mediaAdapter.hideElement(); + } + } + + private navigateForward(): void { + if (this.mediaAdapter.next?.()) { + this.refreshMediaTexture(); + return; + } + + this.mediaController?.forward(); + this.updateSeekBarAppearance(); + } + + private navigateBackward(): void { + if (this.mediaAdapter.previous?.()) { + this.refreshMediaTexture(); + return; + } + + this.mediaController?.rewind(); + this.updateSeekBarAppearance(); + } + + private is2DModeActive(): boolean { + return this.twoDMode?.isActive ?? false; + } + + private closeActiveXrSessionAfterContextLoss(): void { + if (!this.xrSession) return; + + const sessionToClose = this.xrSession; + this.xrSession = null; + sessionToClose.removeEventListener('end', this.handleVrSessionEnd); + sessionToClose.end().catch((e) => { + console.error('Error ending session on context lost:', e); + }).finally(() => { + this.onVRSessionEnd({ session: sessionToClose }); }); - twoDMode = new TwoDMode({ - callbacks: { - createMediaTexture, - forward: navigateForward, - getIsLooping: () => mediaController?.isLooping() ?? false, - positionPlaneForPresentation, - rewind: navigateBackward, - seekToProgress: (progress) => mediaController?.seekToProgress(progress), - showActiveContentMesh, - toggleLoop, - toggleMute: () => mediaController?.toggleMute(), - togglePlayPause: () => mediaController?.togglePlayPause() + } + + private restoreVideoTextureAfterContextRestored(): void { + if (this.sphereMaterial && this.activeContentMesh && this.activeContentMesh.visible && this.renderer.xr.isPresenting && this.xrSession) { + this.textureManager?.assignToMaterial(this.sphereMaterial); + this.updateVRPlayPauseButtonIcon(); + this.updateVRVolumeButtonIcon(); + console.log('Re-initialized media texture after context restoration during VR.'); + } + } + + private getMediaTitle(): string { + return this.mediaAdapter.getTitle() || 'Media Title'; + } + + private updateVRPlayPauseButtonIcon(): void { + if (!this.video) { + return; + } + updateVrPlayPauseButtonIcon(this.vrPanel, this.video.paused || this.video.ended); + } + + private updateVRLoopButtonIcon(): void { + updateVrLoopButtonIcon(this.vrPanel, this.mediaController?.isLooping() ?? false); + } + + private updateVRVolumeButtonIcon(): void { + if (!this.video) { + return; + } + updateVrVolumeButtonIcon(this.vrPanel, this.video.muted || this.video.volume === 0); + } + + private updateSeekBarAppearance(): void { + const progress = this.video && isFinite(this.video.duration) && this.video.duration > 0 + ? this.video.currentTime / this.video.duration + : null; + updateVrSeekBarAppearance(this.vrPanel, progress); + } + + private animatePanelFade(timestamp: number): void { + this.vrPanelVisibility.updateFade(timestamp); + } + + private showPanel(): void { + this.vrPanelVisibility.show(); + } + + private showPanelPersistent(): void { + this.vrPanelVisibility.showPersistent(); + } + + private hidePanel(): void { + this.vrPanelVisibility.hide(); + } + + private getVisibleVrPanelInteractables(): any[] { + return this.vrPanelVisibility.isVisible ? (this.vrPanel?.interactables ?? []) : []; + } + + private onWindowResize(): void { + if (!this.renderer) return; + + if (this.twoDMode?.resize()) return; + + resizePlayerRenderer({ + camera: this.camera, + camera2D: this.camera2D, + is2DMode: false, + onFallbackResize: () => {}, + playerContainer: this.playerContainer, + renderer: this.renderer + }); + } + + private show2DControlPanel(): void { + this.twoDMode?.showControls(); + } + + private hide2DControlPanel(): void { + this.twoDMode?.hideControls(); + } + + private update2DControlPanel(): void { + this.twoDMode?.updateTimeline(); + } + + private update2DPlayPauseButton(): void { + this.twoDMode?.updatePlaybackButton(); + } + + private update2DMuteButton(): void { + this.twoDMode?.updateMuteButton(); + } + + private toggleLoop(): boolean { + const isLooping = this.mediaController?.toggleLoop() ?? false; + this.updateVRLoopButtonIcon(); + return isLooping; + } + + private handle2DVideoEnd(): void { + this.twoDMode?.handleVideoEnd(); + } + + private handleVrVideoEnd(): void { + this.updateVRPlayPauseButtonIcon(); + this.updateSeekBarAppearance(); + this.showPanelPersistent(); + } + + private resetToOriginalState(): void { + if (this.mediaController) { + this.mediaController.resetToOriginalState(); + } else { + this.playBtn.classList.remove('hidden'); + this.playBtn.disabled = false; + } + + if (this.twoDMode?.isActive) { + this.twoDMode.stop(); + this.onWindowResize(); + } + } + + private onVideoEnded(): void { + if (!this.mediaController) { + this.resetToOriginalState(); + return; + } + + this.mediaController.handleEnded({ + isIn2DMode: () => this.is2DModeActive(), + isInVr: () => Boolean(this.xrSession && this.renderer && this.renderer.xr.isPresenting), + on2DEnded: () => this.handle2DVideoEnd(), + onVrEnded: () => this.handleVrVideoEnd(), + resetToOriginalState: () => this.resetToOriginalState() + }); + } + + private onSelectStartVR(event: any): void { + handleVrControllerSelect(event, { + beginSeekDrag: (controller) => { + this.xrInputRig?.beginSeekDrag(controller, this.vrPanel, (progress) => { + this.mediaController?.seekToProgress(progress); + this.updateSeekBarAppearance(); + }); }, - fullscreenTarget: playerContainer, - mediaCapabilities: mediaAdapter.capabilities, - getActiveContentMesh: () => activeContentMesh, - getCamera: () => camera2D, - getCameraControls: () => fallbackCameraControls, - getMaterial: () => sphereMaterial, - getMediaElement: () => mediaAdapter?.element, - getRenderer: () => renderer, - getScene: () => scene, - getVideo: () => video, - playerContainer, - projectionMode, - title: getMediaTitle() + exitVr: () => { + if (this.xrSession) void this.actualSessionToggle(); + }, + forward: () => { + this.navigateForward(); + }, + hidePanel: () => this.hidePanel(), + isPanelVisible: () => this.vrPanelVisibility.isVisible, + raycaster: this.raycaster, + rewind: () => { + this.navigateBackward(); + }, + seek: (progress) => { + this.mediaController?.seekToProgress(progress); + this.updateSeekBarAppearance(); + }, + showPanel: () => this.showPanel(), + toggleMute: () => { + this.mediaController?.toggleMute(); + }, + toggleLoop: () => this.toggleLoop(), + togglePlayPause: () => { + this.mediaController?.togglePlayPause(); + }, + uiElements: this.uiElements, + vrPanel: this.vrPanel }); - } catch (e) { - console.error("INIT_ERROR (Phase 1 - Core Setup):", e); - renderer = null; - return; } - try { // Phase 2: VR Control Panel UI - vrPanel = createVrControlPanel(scene, getMediaTitle(), mediaAdapter?.capabilities); - vrControlPanel = vrPanel.group; - vrPanelVisibility.setPanel(vrPanel); - uiElements.push(...vrPanel.interactables); - - xrInputRig = createVrInputRig(scene, renderer, onSelectStartVR); - raycaster = xrInputRig.raycaster; - } catch (e) { - console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e); - } - - try { // Phase 3: Event Listeners - if (playBtn) { - playBtn.addEventListener('click', handleEnterVRButtonClick); + private async handleEnterVRButtonClick(): Promise { + if (!this.mediaAdapter) { + console.error('Media element not found for VR button click.'); + return; } - window.addEventListener('resize', onWindowResize); - if (video) { - bindVideoEvents({ - onEnded: onVideoEnded, - onPlaybackStateChange: () => { - updateVRPlayPauseButtonIcon(); - update2DPlayPauseButton(); - }, - onTimelineChange: () => { - updateSeekBarAppearance(); - update2DControlPanel(); - }, - onVolumeChange: () => { - updateVRVolumeButtonIcon(); - update2DMuteButton(); - }, - playButton: playBtn, - video + this.hideEnterButton(); + + if (this.playBtn.dataset.xrSupported === 'true') { + await this.actualSessionToggle(); + return; + } + + this.resetHeadPositionLock(); + this.twoDMode?.start(); + } + + private async actualSessionToggle(): Promise { + if (!this.renderer || !this.renderer.isWebGLRenderer) { + console.error('CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!', this.renderer); + return; + } + + if (this.xrSession) { + const sessionToClose = this.xrSession; + this.xrSession = null; + + if (this.vrControlPanel) { + this.vrPanelVisibility.hideImmediately(); + } + sessionToClose.end().catch((err) => { + console.error('Error calling .end() on session:', err); + this.onVRSessionEnd({ session: sessionToClose }); }); + return; } - } catch (e) { - console.error("INIT_ERROR (Phase 3 - Event Listeners):", e); - } -} - -function updateVRPlayPauseButtonIcon() { - if (!video) { - return; - } - updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended); -} - -function updateVRLoopButtonIcon() { - updateVrLoopButtonIcon(vrPanel, mediaController?.isLooping() ?? false); -} - -function updateVRVolumeButtonIcon() { - if (!video) { - return; - } - updateVrVolumeButtonIcon(vrPanel, video.muted || video.volume === 0); -} - -function updateSeekBarAppearance() { - const progress = video && isFinite(video.duration) && video.duration > 0 - ? video.currentTime / video.duration - : null; - updateVrSeekBarAppearance(vrPanel, progress); -} - -function animatePanelFade(timestamp) { - vrPanelVisibility.updateFade(timestamp); -} - -function showPanel() { - vrPanelVisibility.show(); -} - -function showPanelPersistent() { - vrPanelVisibility.showPersistent(); -} - -function hidePanel() { - vrPanelVisibility.hide(); -} - -function getVisibleVrPanelInteractables() { - return vrPanelVisibility.isVisible ? (vrPanel?.interactables ?? []) : []; -} - -function onWindowResize() { - if (!renderer) return; - - if (twoDMode?.resize()) return; - - resizePlayerRenderer({ - camera, - camera2D, - is2DMode: false, - onFallbackResize: () => {}, - playerContainer, - renderer - }); -} - -function show2DControlPanel() { - twoDMode?.showControls(); -} - -function hide2DControlPanel() { - twoDMode?.hideControls(); -} - -function update2DControlPanel() { - twoDMode?.updateTimeline(); -} - -function update2DPlayPauseButton() { - twoDMode?.updatePlaybackButton(); -} - -function update2DMuteButton() { - twoDMode?.updateMuteButton(); -} - -function toggleLoop() { - const isLooping = mediaController?.toggleLoop() ?? false; - updateVRLoopButtonIcon(); - return isLooping; -} - -function handle2DVideoEnd() { - twoDMode?.handleVideoEnd(); -} - -function handleVrVideoEnd() { - updateVRPlayPauseButtonIcon(); - updateSeekBarAppearance(); - showPanelPersistent(); -} - -function resetToOriginalState() { - if (mediaController) { - mediaController.resetToOriginalState(); - } else if (playBtn) { - playBtn.classList.remove('hidden'); - playBtn.disabled = false; - } - - if (twoDMode?.isActive) { - twoDMode.stop(); - onWindowResize(); - } -} - -function onVideoEnded() { - if (!mediaController) { - resetToOriginalState(); - return; - } - - mediaController.handleEnded({ - isIn2DMode: is2DModeActive, - isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting), - on2DEnded: handle2DVideoEnd, - onVrEnded: handleVrVideoEnd, - resetToOriginalState - }); -} - -function onSelectStartVR(event) { - handleVrControllerSelect(event, { - beginSeekDrag: (controller) => { - xrInputRig?.beginSeekDrag(controller, vrPanel, (progress) => { - mediaController?.seekToProgress(progress); - updateSeekBarAppearance(); - }); - }, - exitVr: () => { - if (xrSession) actualSessionToggle(); - }, - forward: () => { - navigateForward(); - }, - hidePanel, - isPanelVisible: () => vrPanelVisibility.isVisible, - raycaster, - rewind: () => { - navigateBackward(); - }, - seek: (progress) => { - mediaController?.seekToProgress(progress); - updateSeekBarAppearance(); - }, - showPanel, - toggleMute: () => { - mediaController?.toggleMute(); - }, - toggleLoop, - togglePlayPause: () => { - mediaController?.togglePlayPause(); - }, - uiElements, - vrPanel - }); -} - -async function handleEnterVRButtonClick() { - if (!mediaAdapter) { - console.error("Media element not found for VR button click."); - return; - } - - hideEnterButton(); - - // Check if VR is supported - if (playBtn.dataset.xrSupported === "true") { - // VR is supported - use VR functionality - await actualSessionToggle(); - } else { - // VR is not supported - start 2D rectilinear mode - resetHeadPositionLock(); - twoDMode?.start(); - } -} - -async function actualSessionToggle() { - if (!renderer || !renderer.isWebGLRenderer) { - console.error("CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!", renderer); - return; - } - - if (xrSession) { // --- EXITING VR --- - const sessionToClose = xrSession; - xrSession = null; - - if (vrControlPanel) { - vrPanelVisibility.hideImmediately(); - } - sessionToClose.end().catch(err => { - console.error("Error calling .end() on session:", err); - onVRSessionEnd({ session: sessionToClose }); - }); - } else { // --- ENTERING VR --- try { const session = await navigator.xr.requestSession('immersive-vr', { requiredFeatures: ['local-floor'], - optionalFeatures: ['hand-tracking'], + optionalFeatures: ['hand-tracking'] }); - if (!session) { throw new Error("requestSession returned no session."); } + if (!session) { throw new Error('requestSession returned no session.'); } - xrSession = session; - xrSession.addEventListener('end', onVRSessionEnd); + this.xrSession = session; + this.xrSession.addEventListener('end', this.handleVrSessionEnd); + this.mediaAdapter.hideElement(); - mediaAdapter?.hideElement(); - - if (mediaController && video && (video.paused || video.ended)) { + if (this.mediaController && this.video && (this.video.paused || this.video.ended)) { try { - await mediaController.play(); + await this.mediaController.play(); } catch (playError) { - console.error("Failed to play video after obtaining XR session:", playError); + console.error('Failed to play video after obtaining XR session:', playError); } } - if (camera) camera.updateProjectionMatrix(); - positionPlaneForPresentation(false); + if (this.camera) this.camera.updateProjectionMatrix(); + this.positionPlaneForPresentation(false); - textureManager?.dispose(); - if (!mediaAdapter) { - throw new Error("Media adapter not available for creating texture."); + this.textureManager?.dispose(); + if (!this.activeContentMesh || !this.sphereMaterial) { + throw new Error('VR mesh components not ready for texture.'); } - if (!activeContentMesh || !sphereMaterial) { - throw new Error("VR mesh components not ready for texture."); + if (!this.textureManager) { + throw new Error('Media texture manager is not initialized.'); } - if (!textureManager) { - throw new Error("Media texture manager is not initialized."); - } - textureManager.assignToMaterial(sphereMaterial); - showActiveContentMesh(); + this.textureManager.assignToMaterial(this.sphereMaterial); + this.showActiveContentMesh(); - updateVRPlayPauseButtonIcon(); - updateVRLoopButtonIcon(); - updateVRVolumeButtonIcon(); - if (vrControlPanel) { - vrPanelVisibility.hideImmediately(); + this.updateVRPlayPauseButtonIcon(); + this.updateVRLoopButtonIcon(); + this.updateVRVolumeButtonIcon(); + if (this.vrControlPanel) { + this.vrPanelVisibility.hideImmediately(); } - await renderer.xr.setSession(xrSession); - xrInputRig?.showOverlays(); - isXrLoopActive = true; - renderer.setAnimationLoop(renderXR); - frameCounter = 0; - + await this.renderer.xr.setSession(this.xrSession); + this.xrInputRig?.showOverlays(); + this.isXrLoopActive = true; + this.renderer.setAnimationLoop(this.renderXrFrame); + this.frameCounter = 0; } catch (err) { - const sessionStartError = "XR_ERROR: Failed to start VR session: " + (err.message || String(err)); + const sessionStartError = 'XR_ERROR: Failed to start VR session: ' + (err.message || String(err)); console.error(sessionStartError, err); - isXrLoopActive = false; + this.isXrLoopActive = false; - hideContentMeshes(); - textureManager?.clearMaterial(sphereMaterial); - if (vrControlPanel) { - vrPanelVisibility.hideImmediately(); + this.hideContentMeshes(); + this.textureManager?.clearMaterial(this.sphereMaterial); + if (this.vrControlPanel) { + this.vrPanelVisibility.hideImmediately(); } - if (xrSession) { - xrSession.removeEventListener('end', onVRSessionEnd); - const tempSession = xrSession; - xrSession = null; - tempSession.end().catch(e => {}).finally(() => { - onVRSessionEnd({session: tempSession}); + if (this.xrSession) { + this.xrSession.removeEventListener('end', this.handleVrSessionEnd); + const tempSession = this.xrSession; + this.xrSession = null; + tempSession.end().catch(() => {}).finally(() => { + this.onVRSessionEnd({ session: tempSession }); }); } else { - onVRSessionEnd({session: null}); + this.onVRSessionEnd({ session: null }); } - if (renderer && renderer.getAnimationLoop && renderer.getAnimationLoop()) { - renderer.setAnimationLoop(null); + if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) { + this.renderer.setAnimationLoop(null); + } + } + } + + private hideEnterButton(): void { + if (this.mediaController) { + this.mediaController.hidePlayButton(); + return; + } + + this.playBtn.classList.add('hidden'); + } + + private onVRSessionEnd(event: any): void { + const endedSession = event.session; + + this.isXrLoopActive = false; + if (this.renderer) { + if (this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) { + this.renderer.setAnimationLoop(null); + } + } + + this.mediaAdapter.showElement(); + + this.mediaController?.pauseIfPlaying(); + + this.textureManager?.clearMaterial(this.sphereMaterial); + this.hideContentMeshes(); + this.resetHeadPositionLock(); + if (this.vrControlPanel) { + this.vrPanelVisibility.hideImmediately(); + } + this.xrInputRig?.hideOverlays(); + + if (endedSession && typeof endedSession.removeEventListener === 'function') { + endedSession.removeEventListener('end', this.handleVrSessionEnd); + } + + if (this.xrSession === endedSession || this.xrSession === null) { + this.xrSession = null; + } else if (this.xrSession && endedSession) { + console.warn('onVRSessionEnd: Global xrSession was different from the endedSession. Global xrSession:', this.xrSession, 'Ended session:', endedSession); + this.xrSession = null; + } + + this.resetToOriginalState(); + this.onWindowResize(); + } + + private renderXR(timestamp: number, frame: any): void { + if (!this.isXrLoopActive) { + return; + } + this.frameCounter++; + + if (!this.renderer || !this.renderer.xr || !this.renderer.xr.isPresenting) { + console.warn('renderXR called but not in a valid XR presenting state. Stopping loop.'); + this.isXrLoopActive = false; + if (this.renderer && this.renderer.getAnimationLoop && this.renderer.getAnimationLoop()) { + this.renderer.setAnimationLoop(null); + } + return; + } + + if (this.vrPanelVisibility.isFading) { + this.animatePanelFade(timestamp); + } + this.xrInputRig?.update(timestamp, this.getVisibleVrPanelInteractables()); + + if (!frame) { + console.warn('renderXR called without an XRFrame. Skipping render.'); + return; + } + + if (this.frameCounter > 0 && this.frameCounter % 3600 === 0) { + const gl = this.renderer.getContext(); + const error = gl.getError(); + if (error !== gl.NO_ERROR) { + console.error(`WEBGL_ERROR_IN_RENDER_LOOP (F${this.frameCounter}):`, error, gl.enumToString ? gl.enumToString(error) : error); + } + } + + try { + this.updateHeadPositionLock(); + this.textureManager?.updateIfNeeded(); + this.renderer.render(this.scene, this.camera); + } catch (error) { + const renderErrorMsg = 'ERROR_IN_RENDERXR_LOOP (F' + this.frameCounter + '): ' + (error.message || String(error)); + console.error(renderErrorMsg, error); + console.error('Render loop error. Attempting to exit VR.'); + this.isXrLoopActive = false; + + const sessionToCloseOnError = this.xrSession; + this.xrSession = null; + + if (sessionToCloseOnError) { + sessionToCloseOnError.removeEventListener('end', this.handleVrSessionEnd); + sessionToCloseOnError.end().catch((e) => { + console.error('Error trying to end session after render loop crash:', e); + }).finally(() => { + this.onVRSessionEnd({ session: sessionToCloseOnError }); + }); + } else { + this.onVRSessionEnd({ session: null }); } } } } -function hideEnterButton() { - if (mediaController) { - mediaController.hidePlayButton(); - return; - } +const playerBase = new URL('.', import.meta.url).href; +let activeSession: PlayerSession | undefined; - playBtn?.classList.add('hidden'); -} - -function onVRSessionEnd(event) { - const endedSession = event.session; - - isXrLoopActive = false; - if (renderer) { - if (renderer.getAnimationLoop && renderer.getAnimationLoop()) { - renderer.setAnimationLoop(null); - } - } - - mediaAdapter?.showElement(); - - mediaController?.pauseIfPlaying(); - - textureManager?.clearMaterial(sphereMaterial); - hideContentMeshes(); - resetHeadPositionLock(); - if (vrControlPanel) { - vrPanelVisibility.hideImmediately(); - } - xrInputRig?.hideOverlays(); - - if (endedSession && typeof endedSession.removeEventListener === 'function') { - endedSession.removeEventListener('end', onVRSessionEnd); - } - - if (xrSession === endedSession || xrSession === null) { - xrSession = null; - } else if (xrSession && endedSession) { - console.warn("onVRSessionEnd: Global xrSession was different from the endedSession. Global xrSession:", xrSession, "Ended session:", endedSession); - xrSession = null; - } - - // Reset to original state when exiting VR - resetToOriginalState(); - - onWindowResize(); -} - - -function renderXR(timestamp, frame) { - if (!isXrLoopActive) { - return; - } - frameCounter++; - - if (!renderer || !renderer.xr || !renderer.xr.isPresenting) { - console.warn("renderXR called but not in a valid XR presenting state. Stopping loop."); - isXrLoopActive = false; - if (renderer && renderer.getAnimationLoop && renderer.getAnimationLoop()) { - renderer.setAnimationLoop(null); - } - return; - } - - if (vrPanelVisibility.isFading) { - animatePanelFade(timestamp); - } - xrInputRig?.update(timestamp, getVisibleVrPanelInteractables()); - - if (!frame) { - console.warn("renderXR called without an XRFrame. Skipping render."); - return; - } - - if (frameCounter > 0 && frameCounter % 3600 === 0) { - const gl = renderer.getContext(); - const error = gl.getError(); - if (error !== gl.NO_ERROR) { - console.error(`WEBGL_ERROR_IN_RENDER_LOOP (F${frameCounter}):`, error, gl.enumToString ? gl.enumToString(error) : error); - } - } - try { - updateHeadPositionLock(); - textureManager?.updateIfNeeded(); - renderer.render(scene, camera); - } catch (error) { - const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error)); - console.error(renderErrorMsg, error); - console.error("Render loop error. Attempting to exit VR."); - isXrLoopActive = false; - - const sessionToCloseOnError = xrSession; - xrSession = null; - - if (sessionToCloseOnError) { - sessionToCloseOnError.removeEventListener('end', onVRSessionEnd); - sessionToCloseOnError.end().catch(e => { - console.error("Error trying to end session after render loop crash:", e); - }).finally(() => { - onVRSessionEnd({ session: sessionToCloseOnError }); - }); - } else { - onVRSessionEnd({ session: null }); - } - } -} +bootstrapPlayer(playerBase, (context) => { + activeSession = new PlayerSession(context); + activeSession.init(); +}); diff --git a/src/vr180player/xr/hand-aim-three.ts b/src/vr180player/xr/hand-aim-three.ts new file mode 100644 index 0000000..4f108b9 --- /dev/null +++ b/src/vr180player/xr/hand-aim-three.ts @@ -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 + }; +} diff --git a/src/vr180player/xr/input-rig.ts b/src/vr180player/xr/input-rig.ts index f32c33b..a8e7c9c 100644 --- a/src/vr180player/xr/input-rig.ts +++ b/src/vr180player/xr/input-rig.ts @@ -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(); } diff --git a/src/vr180player/xr/vr-control-panel.ts b/src/vr180player/xr/vr-control-panel.ts index b23ed7c..6c3729b 100644 --- a/src/vr180player/xr/vr-control-panel.ts +++ b/src/vr180player/xr/vr-control-panel.ts @@ -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; -} diff --git a/src/vr180player/xr/vr-controller-interactions.ts b/src/vr180player/xr/vr-controller-interactions.ts index 92609e1..50e86d8 100644 --- a/src/vr180player/xr/vr-controller-interactions.ts +++ b/src/vr180player/xr/vr-controller-interactions.ts @@ -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); -} diff --git a/src/vr180player/xr/vr-panel-button-textures.ts b/src/vr180player/xr/vr-panel-button-textures.ts new file mode 100644 index 0000000..3c0c74b --- /dev/null +++ b/src/vr180player/xr/vr-panel-button-textures.ts @@ -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); +} diff --git a/src/vr180player/xr/vr-panel-layout.ts b/src/vr180player/xr/vr-panel-layout.ts new file mode 100644 index 0000000..c47c867 --- /dev/null +++ b/src/vr180player/xr/vr-panel-layout.ts @@ -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; diff --git a/src/vr180player/xr/vr-panel-meshes.ts b/src/vr180player/xr/vr-panel-meshes.ts new file mode 100644 index 0000000..bcb5dd1 --- /dev/null +++ b/src/vr180player/xr/vr-panel-meshes.ts @@ -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; +} diff --git a/tests/input-mode.test.mjs b/tests/input-mode.test.mjs new file mode 100644 index 0000000..246036c --- /dev/null +++ b/tests/input-mode.test.mjs @@ -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); +});