import { PLANE_2D_DISTANCE, PLANE_DISTANCE, type HeadLockMode, type ProjectionMode } from './config.js'; import { bootstrapPlayer, type BootstrapContext } from './bootstrap.js'; import { createContentScene } from './rendering/content-scene.js'; import { applyHeadPositionLock as applyHeadPositionLockCore, applySbsTextureWindow as applySbsTextureWindowCore, hideContentMeshes as hideContentMeshesCore, positionPlaneForPresentation as positionPlaneForPresentationCore, resetHeadPositionLockedContent as resetHeadPositionLockedContentCore, shouldLockContentToHeadPosition, showActiveContentMesh as showActiveContentMeshCore } from './rendering/projection.js'; import { createMediaTexture as createMediaTextureCore } from './rendering/three-utils.js'; import { FallbackCameraControls } from './modes/fallback-camera-controls.js'; import { MediaController } from './media/media-controller.js'; import { createVrInputRig } from './xr/input-rig.js'; import { handleVrControllerSelect } from './xr/vr-controller-interactions.js'; import { bindVideoEvents } from './media/video-events.js'; import { createVrControlPanel, type VrControlPanel, updateVrLoopButtonIcon, updateVrPlayPauseButtonIcon, updateVrSeekBarAppearance, updateVrVolumeButtonIcon } from './xr/vr-control-panel.js'; import { VrPanelVisibility } from './xr/vr-panel-visibility.js'; import { TwoDMode } from './modes/two-d-mode.js'; import { createPlayerRenderer, resizePlayerRenderer } from './rendering/renderer-lifecycle.js'; import { MediaTextureManager } from './rendering/texture-manager.js'; import type { SupportedMediaAdapter } from './media/media-adapter.js'; 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); 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; 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; } 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; 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; 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); 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; } 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 ); } 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 }); }); } 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(); }); }, 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 }); } private async handleEnterVRButtonClick(): Promise { if (!this.mediaAdapter) { console.error('Media element not found for VR button click.'); return; } 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; } try { const session = await navigator.xr.requestSession('immersive-vr', { requiredFeatures: ['local-floor'], optionalFeatures: ['hand-tracking'] }); if (!session) { throw new Error('requestSession returned no session.'); } this.xrSession = session; this.xrSession.addEventListener('end', this.handleVrSessionEnd); this.mediaAdapter.hideElement(); if (this.mediaController && this.video && (this.video.paused || this.video.ended)) { try { await this.mediaController.play(); } catch (playError) { console.error('Failed to play video after obtaining XR session:', playError); } } if (this.camera) this.camera.updateProjectionMatrix(); this.positionPlaneForPresentation(false); this.textureManager?.dispose(); if (!this.activeContentMesh || !this.sphereMaterial) { throw new Error('VR mesh components not ready for texture.'); } if (!this.textureManager) { throw new Error('Media texture manager is not initialized.'); } this.textureManager.assignToMaterial(this.sphereMaterial); this.showActiveContentMesh(); this.updateVRPlayPauseButtonIcon(); this.updateVRLoopButtonIcon(); this.updateVRVolumeButtonIcon(); if (this.vrControlPanel) { this.vrPanelVisibility.hideImmediately(); } 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)); console.error(sessionStartError, err); this.isXrLoopActive = false; this.hideContentMeshes(); this.textureManager?.clearMaterial(this.sphereMaterial); if (this.vrControlPanel) { this.vrPanelVisibility.hideImmediately(); } 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 { this.onVRSessionEnd({ session: 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 }); } } } } const playerBase = new URL('.', import.meta.url).href; let activeSession: PlayerSession | undefined; bootstrapPlayer(playerBase, (context) => { activeSession = new PlayerSession(context); activeSession.init(); });