1
0
Files
VR-Web-Player/src/vr180player/vr180-player.ts
2026-06-10 12:48:36 +10:00

598 lines
16 KiB
TypeScript

import {
PLANE_2D_DISTANCE,
PLANE_DISTANCE,
type ProjectionMode
} from './config.js';
import { bootstrapPlayer } from './bootstrap.js';
import { createContentScene } from './rendering/content-scene.js';
import {
applySbsTextureWindow as applySbsTextureWindowCore,
hideContentMeshes as hideContentMeshesCore,
positionPlaneForPresentation as positionPlaneForPresentationCore,
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 {
createVrController,
handleVrControllerSelect
} from './xr/vr-controller-interactions.js';
import { bindVideoEvents } from './media/video-events.js';
import {
createVrControlPanel,
type VrControlPanel,
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';
const _playerBase = new URL('.', import.meta.url).href;
let playerContainer, projectionMode: ProjectionMode;
let scene, camera, renderer, video, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
let raycaster, uiElements = [];
let mediaAdapter: SupportedMediaAdapter | undefined;
let playBtn;
let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
let textureManager: MediaTextureManager<HTMLImageElement | HTMLVideoElement> | 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;
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 createMediaTexture() {
if (!textureManager) {
throw new Error('Media texture manager is not initialized.');
}
return textureManager.create();
}
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.');
}
video = mediaAdapter.kind === 'video' ? mediaAdapter.element : undefined;
textureManager = new MediaTextureManager(
mediaAdapter.textureSource,
createMediaTextureCore,
() => mediaAdapter?.shouldUpdateTexture() ?? false
);
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
});
twoDMode = new TwoDMode({
callbacks: {
createMediaTexture,
forward: () => mediaController?.forward(),
positionPlaneForPresentation,
rewind: () => mediaController?.rewind(),
seekToProgress: (progress) => mediaController?.seekToProgress(progress),
showActiveContentMesh,
toggleMute: () => mediaController?.toggleMute(),
togglePlayPause: () => mediaController?.togglePlayPause()
},
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()
});
} 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);
raycaster = createVrController(scene, renderer, onSelectStartVR).raycaster;
} catch (e) {
console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e);
}
try { // Phase 3: Event Listeners
if (playBtn) {
playBtn.addEventListener('click', handleEnterVRButtonClick);
}
window.addEventListener('resize', onWindowResize);
if (video) {
bindVideoEvents({
onEnded: onVideoEnded,
onPlaybackStateChange: () => {
updateVRPlayPauseButtonIcon();
update2DPlayPauseButton();
},
onTimelineChange: () => {
updateSeekBarAppearance();
update2DControlPanel();
},
onVolumeChange: () => {
updateVRVolumeButtonIcon();
update2DMuteButton();
},
playButton: playBtn,
video
});
}
} catch (e) {
console.error("INIT_ERROR (Phase 3 - Event Listeners):", e);
}
}
function updateVRPlayPauseButtonIcon() {
if (!video) {
return;
}
updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended);
}
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 hidePanel() {
vrPanelVisibility.hide();
}
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 handle2DVideoEnd() {
twoDMode?.handleVideoEnd();
}
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({
cleanupFailedVrExit,
exitVr: actualSessionToggle,
isIn2DMode: is2DModeActive,
isInVr: () => Boolean(xrSession && renderer && renderer.xr.isPresenting),
on2DEnded: handle2DVideoEnd,
resetToOriginalState
});
}
function cleanupFailedVrExit() {
if (xrSession) {
const sessionToClean = xrSession;
xrSession = null;
sessionToClean.removeEventListener('end', onVRSessionEnd);
sessionToClean.end().catch(e => {}).finally(() => onVRSessionEnd({session: sessionToClean}));
} else {
onVRSessionEnd({session: null});
}
}
function onSelectStartVR(event) {
handleVrControllerSelect(event, {
exitVr: () => {
if (xrSession) actualSessionToggle();
},
forward: () => {
mediaController?.forward();
updateSeekBarAppearance();
},
hidePanel,
isPanelVisible: () => vrPanelVisibility.isVisible,
raycaster,
rewind: () => {
mediaController?.rewind();
updateSeekBarAppearance();
},
seek: (progress) => {
mediaController?.seekToProgress(progress);
updateSeekBarAppearance();
},
showPanel,
toggleMute: () => {
mediaController?.toggleMute();
},
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
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'],
});
if (!session) { throw new Error("requestSession returned no session."); }
xrSession = session;
xrSession.addEventListener('end', onVRSessionEnd);
mediaAdapter?.hideElement();
if (mediaController && video && (video.paused || video.ended)) {
try {
await mediaController.play();
} catch (playError) {
console.error("Failed to play video after obtaining XR session:", playError);
}
}
if (camera) camera.updateProjectionMatrix();
positionPlaneForPresentation(false);
textureManager?.dispose();
if (!mediaAdapter) {
throw new Error("Media adapter not available for creating texture.");
}
if (!activeContentMesh || !sphereMaterial) {
throw new Error("VR mesh components not ready for texture.");
}
if (!textureManager) {
throw new Error("Media texture manager is not initialized.");
}
textureManager.assignToMaterial(sphereMaterial);
showActiveContentMesh();
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
await renderer.xr.setSession(xrSession);
isXrLoopActive = true;
renderer.setAnimationLoop(renderXR);
frameCounter = 0;
} catch (err) {
const sessionStartError = "XR_ERROR: Failed to start VR session: " + (err.message || String(err));
console.error(sessionStartError, err);
isXrLoopActive = false;
hideContentMeshes();
textureManager?.clearMaterial(sphereMaterial);
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
if (xrSession) {
xrSession.removeEventListener('end', onVRSessionEnd);
const tempSession = xrSession;
xrSession = null;
tempSession.end().catch(e => {}).finally(() => {
onVRSessionEnd({session: tempSession});
});
} else {
onVRSessionEnd({session: null});
}
if (renderer && renderer.getAnimationLoop && renderer.getAnimationLoop()) {
renderer.setAnimationLoop(null);
}
}
}
}
function hideEnterButton() {
if (mediaController) {
mediaController.hidePlayButton();
return;
}
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();
if (vrControlPanel) {
vrPanelVisibility.hideImmediately();
}
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);
}
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 {
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 });
}
}
}