1
0
Files
VR-Web-Player/src/vr180player/vr180-player.ts
Aiden a470d4bdc7
All checks were successful
Test / test (push) Successful in 9m30s
additions and refactors
2026-06-11 05:27:20 +10:00

686 lines
21 KiB
TypeScript

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<HTMLImageElement | HTMLVideoElement> | 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<void> {
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<void> {
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();
});