1
0
Files
VR-Web-Player/src/vr180player/vr180-player.ts
2026-06-10 11:55:14 +10:00

662 lines
18 KiB
TypeScript

import {
DEFAULT_PROJECTION,
PLANE_2D_DISTANCE,
PLANE_DISTANCE,
PLAYER_SELECTOR,
type ProjectionMode,
VALID_PROJECTIONS
} from './config.js';
import { createContentScene } from './rendering/content-scene.js';
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom/dom.js';
import {
applySbsTextureWindow as applySbsTextureWindowCore,
hideContentMeshes as hideContentMeshesCore,
positionPlaneForPresentation as positionPlaneForPresentationCore,
showActiveContentMesh as showActiveContentMeshCore
} from './rendering/projection.js';
import { createVideoTexture as createVideoTextureCore } 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';
const _playerBase = new URL('.', import.meta.url).href;
let playerContainer, projectionMode: ProjectionMode = DEFAULT_PROJECTION;
let scene, camera, renderer, video, videoTexture, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null;
let raycaster, uiElements = [];
let videoElement, playBtn;
let frameCounter = 0;
let isXrLoopActive = false;
let vrControlPanel;
let mediaController: MediaController | undefined;
let vrPanel: VrControlPanel | undefined;
let twoDMode: TwoDMode | undefined;
const vrPanelVisibility = new VrPanelVisibility();
// 2D Camera Controls
let camera2D;
let fallbackCameraControls: FallbackCameraControls | undefined;
injectPlayerStyles(_playerBase);
document.addEventListener('DOMContentLoaded', () => {
const containers = document.querySelectorAll(PLAYER_SELECTOR);
if (containers.length === 0) {
console.error(`VR_WEB_PLAYER_DOM: Expected exactly one ${PLAYER_SELECTOR} container, found none.`);
return;
}
if (containers.length > 1) {
console.warn(`VR_WEB_PLAYER_DOM: This version supports exactly one ${PLAYER_SELECTOR} container per page. Found ${containers.length}; no player was initialized.`);
return;
}
playerContainer = containers[0];
playerContainer.classList.add('vrwp');
const configuredProjection = (playerContainer.dataset.projection || DEFAULT_PROJECTION).trim().toLowerCase();
if (!VALID_PROJECTIONS.has(configuredProjection as ProjectionMode)) {
console.error(`VR_WEB_PLAYER_CONFIG: Unsupported data-projection="${configuredProjection}". Use "vr180" or "plane".`);
return;
}
projectionMode = configuredProjection as ProjectionMode;
videoElement = playerContainer.querySelector('video');
if (!videoElement) {
console.error(`VR_WEB_PLAYER_DOM: ${PLAYER_SELECTOR} must contain a video element.`);
return;
}
videoElement.classList.add('vrwp-video');
// Create and insert play button
playBtn = createPlayButton();
playerContainer.appendChild(playBtn);
// Create and insert 2D control panel
const controlPanel = create2DControlPanel();
playerContainer.appendChild(controlPanel);
playBtn.disabled = true;
if (videoElement) {
videoElement.load();
}
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-vr').then((supported) => {
if (supported) {
playBtn.dataset.xrSupported = "true";
} else {
playBtn.dataset.xrSupported = "false";
// Enable button for regular video playback when VR is not supported
playBtn.disabled = false;
}
// Always call init() regardless of VR support
init();
}).catch(err => {
console.error("XR Support Check Error:", err);
playBtn.dataset.xrSupported = "false";
// Enable button for regular video playback when VR check fails
playBtn.disabled = false;
// Call init() even when VR check fails
init();
});
} else {
playBtn.dataset.xrSupported = "false";
// If navigator.xr itself is not available, enable button for regular video playback
if (playBtn) {
playBtn.disabled = false;
}
// Call init() even when XR is not available
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 createVideoTexture() {
if (videoTexture) videoTexture.dispose();
videoTexture = createVideoTextureCore(video);
return videoTexture;
}
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 (video && sphereMaterial && activeContentMesh && activeContentMesh.visible && renderer.xr.isPresenting && xrSession) {
videoTexture = createVideoTexture();
sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true;
updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon();
console.log("Re-initialized video texture after context restoration during VR.");
}
}
function getVideoTitle() {
return videoElement.getAttribute('title') ||
videoElement.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
"Video Title";
}
function init() {
try {
const playerRenderer = createPlayerRenderer(playerContainer, {
closeActiveXrSession: closeActiveXrSessionAfterContextLoss,
hasActiveXrSession: () => !!xrSession,
restoreAfterContextRestored: restoreVideoTextureAfterContextRestored
});
scene = playerRenderer.scene;
camera = playerRenderer.camera;
renderer = playerRenderer.renderer;
video = videoElement;
mediaController = new MediaController({
is2DModeActive,
on2DPlaybackResume: show2DControlPanel,
playButton: playBtn,
video
});
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: createVideoTexture,
forward: () => mediaController?.forward(),
positionPlaneForPresentation,
rewind: () => mediaController?.rewind(),
seekToProgress: (progress) => mediaController?.seekToProgress(progress),
showActiveContentMesh,
toggleMute: () => mediaController?.toggleMute(),
togglePlayPause: () => mediaController?.togglePlayPause()
},
fullscreenTarget: playerContainer,
getActiveContentMesh: () => activeContentMesh,
getCamera: () => camera2D,
getCameraControls: () => fallbackCameraControls,
getMaterial: () => sphereMaterial,
getRenderer: () => renderer,
getScene: () => scene,
getVideo: () => video,
playerContainer,
projectionMode,
title: getVideoTitle()
});
} catch (e) {
console.error("INIT_ERROR (Phase 1 - Core Setup):", e);
renderer = null;
return;
}
try { // Phase 2: VR Control Panel UI
vrPanel = createVrControlPanel(scene, getVideoTitle());
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 (!video) {
console.error("Video element not found for VR button click.");
return;
}
// Hide the play button after click
mediaController?.hidePlayButton();
// 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);
// Hide the regular video element when entering VR
if (video) {
video.style.display = 'none';
}
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);
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
if (video) {
videoTexture = createVideoTexture();
if (activeContentMesh && sphereMaterial) {
sphereMaterial.map = videoTexture;
sphereMaterial.needsUpdate = true;
showActiveContentMesh();
} else { throw new Error("VR mesh components not ready for texture."); }
} else {
throw new Error("Video element not available for creating texture.");
}
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();
if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
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 onVRSessionEnd(event) {
const endedSession = event.session;
isXrLoopActive = false;
if (renderer) {
if (renderer.getAnimationLoop && renderer.getAnimationLoop()) {
renderer.setAnimationLoop(null);
}
}
// Show the regular video element when exiting VR
if (video) {
video.style.display = '';
}
mediaController?.pauseIfPlaying();
if (sphereMaterial && sphereMaterial.map) {
sphereMaterial.map.dispose();
sphereMaterial.map = null;
sphereMaterial.needsUpdate = true;
}
if (videoTexture) {
videoTexture.dispose(); videoTexture = null;
}
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 {
// Sync video texture before render to ensure frame consistency
if (videoTexture && video && !video.paused && !video.ended) {
videoTexture.needsUpdate = true;
}
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 });
}
}
}