import { DEFAULT_PROJECTION, PLANE_2D_DISTANCE, PLANE_DISTANCE, PLAYER_SELECTOR, type ProjectionMode, VALID_PROJECTIONS } from './config.js'; import { createContentScene } from './content-scene.js'; import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom.js'; import { applySbsTextureWindow as applySbsTextureWindowCore, hideContentMeshes as hideContentMeshesCore, positionPlaneForPresentation as positionPlaneForPresentationCore, showActiveContentMesh as showActiveContentMeshCore } from './projection.js'; import { createVideoTexture as createVideoTextureCore } from './three-utils.js'; import { FallbackCameraControls } from './fallback-camera-controls.js'; import { MediaController } from './media-controller.js'; import { createVrController, handleVrControllerSelect } from './vr-controller-interactions.js'; import { bindVideoEvents } from './video-events.js'; import { createVrControlPanel, type VrControlPanel, updateVrPlayPauseButtonIcon, updateVrSeekBarAppearance, updateVrVolumeButtonIcon } from './vr-control-panel.js'; import { VrPanelVisibility } from './vr-panel-visibility.js'; import { TwoDMode } from './two-d-mode.js'; import { createPlayerRenderer, resizePlayerRenderer } from './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 }); } } }