diff --git a/src/vr180player/media-controller.ts b/src/vr180player/media-controller.ts new file mode 100644 index 0000000..c6d91c6 --- /dev/null +++ b/src/vr180player/media-controller.ts @@ -0,0 +1,133 @@ +type MediaControllerOptions = { + is2DModeActive: () => boolean; + on2DPlaybackResume: () => void; + playButton?: HTMLButtonElement; + video: HTMLVideoElement; +}; + +type HandleMediaEndedOptions = { + cleanupFailedVrExit: () => void; + exitVr: () => Promise; + isIn2DMode: () => boolean; + isInVr: () => boolean; + on2DEnded: () => void; + resetToOriginalState: () => void; +}; + +const DEFAULT_SKIP_SECONDS = 15; + +export class MediaController { + private readonly is2DModeActive: () => boolean; + private readonly on2DPlaybackResume: () => void; + private readonly playButton?: HTMLButtonElement; + private readonly video: HTMLVideoElement; + + constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) { + this.is2DModeActive = is2DModeActive; + this.on2DPlaybackResume = on2DPlaybackResume; + this.playButton = playButton; + this.video = video; + } + + enableNativeControls(): void { + this.video.controls = true; + } + + forward(seconds = DEFAULT_SKIP_SECONDS): void { + if (!isFinite(this.video.duration)) return; + + this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds); + } + + handleEnded({ + cleanupFailedVrExit, + exitVr, + isIn2DMode, + isInVr, + on2DEnded, + resetToOriginalState + }: HandleMediaEndedOptions): void { + this.pauseIfPlaying(); + + if (isInVr()) { + exitVr().catch((err) => { + console.error('Error during automatic VR exit on video end:', err); + cleanupFailedVrExit(); + }); + return; + } + + if (isIn2DMode()) { + on2DEnded(); + return; + } + + resetToOriginalState(); + } + + hidePlayButton(): void { + this.playButton?.classList.add('hidden'); + } + + pauseIfPlaying(): void { + if (!this.video.paused) { + this.video.pause(); + } + } + + play(): Promise { + return this.video.play(); + } + + resetToOriginalState(): void { + this.video.pause(); + this.video.currentTime = 0; + this.video.controls = false; + this.video.load(); + + this.playButton?.classList.remove('hidden'); + if (this.playButton) { + this.playButton.disabled = false; + } + } + + rewind(seconds = DEFAULT_SKIP_SECONDS): void { + this.video.currentTime = Math.max(0, this.video.currentTime - seconds); + } + + seekToProgress(progress: number): void { + if (!isFinite(this.video.duration)) return; + + this.video.currentTime = progress * this.video.duration; + } + + toggleMute(): void { + this.video.muted = !this.video.muted; + } + + togglePlayPause(): void { + if (!this.video.currentSrc) return; + + if (this.video.paused || this.video.ended) { + if (this.video.ended && this.is2DModeActive()) { + this.video.currentTime = 0; + } + + if (this.video.readyState >= this.video.HAVE_ENOUGH_DATA || this.video.currentSrc) { + const playPromise = this.video.play() as Promise | undefined; + if (playPromise !== undefined) { + playPromise.then(() => { + if (this.is2DModeActive() && this.video.ended === false) { + this.on2DPlaybackResume(); + } + }).catch((err) => console.error('Error during video.play():', err)); + } else { + console.error('video.play() did not return a promise.'); + } + } + return; + } + + this.video.pause(); + } +} diff --git a/src/vr180player/renderer-lifecycle.ts b/src/vr180player/renderer-lifecycle.ts new file mode 100644 index 0000000..6513dbb --- /dev/null +++ b/src/vr180player/renderer-lifecycle.ts @@ -0,0 +1,150 @@ +import * as THREE from 'https://unpkg.com/three/build/three.module.js'; + +const FALLBACK_ASPECT_RATIO = 16 / 9; +const MIN_FALLBACK_CANVAS_WIDTH = 320; +const MIN_FALLBACK_CANVAS_HEIGHT = 180; + +type RendererContextHandlers = { + closeActiveXrSession: () => void; + hasActiveXrSession: () => boolean; + restoreAfterContextRestored: () => void; +}; + +type PlayerRenderer = { + camera: any; + renderer: any; + scene: any; +}; + +type ResizePlayerRendererOptions = { + camera: any; + camera2D: any; + is2DMode: boolean; + onFallbackResize: () => void; + playerContainer: HTMLElement; + renderer: any; +}; + +type ResizeFallbackRendererOptions = { + camera2D: any; + playerContainer: HTMLElement; + renderer: any; +}; + +export function createPlayerRenderer( + playerContainer: HTMLElement, + contextHandlers: RendererContextHandlers +): PlayerRenderer { + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); + camera.position.set(0, 1.6, 0.1); + scene.add(camera); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + if (!renderer || !renderer.isWebGLRenderer) { + throw new Error("Failed to create WebGLRenderer or it's not a valid Three.js renderer type."); + } + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.xr.enabled = true; + renderer.outputColorSpace = THREE.SRGBColorSpace; + playerContainer.appendChild(renderer.domElement); + + hideRendererCanvas(renderer); + bindWebGlContextEvents(renderer, contextHandlers); + + return { camera, renderer, scene }; +} + +export function hideRendererCanvas(renderer: any): void { + if (renderer?.domElement) { + renderer.domElement.style.display = 'none'; + } +} + +export function resizeFallbackRenderer({ + camera2D, + playerContainer, + renderer +}: ResizeFallbackRendererOptions): void { + const { height, width } = getFallbackCanvasSize(playerContainer); + + renderer.setSize(width, height); + camera2D.aspect = width / height; + camera2D.updateProjectionMatrix(); + styleFallbackCanvas(renderer.domElement); +} + +export function resizePlayerRenderer({ + camera, + camera2D, + is2DMode, + onFallbackResize, + playerContainer, + renderer +}: ResizePlayerRendererOptions): void { + if (!renderer) return; + if (renderer.xr && renderer.xr.isPresenting) return; + + if (is2DMode) { + if (!playerContainer || !camera2D) return; + resizeFallbackRenderer({ camera2D, playerContainer, renderer }); + onFallbackResize(); + return; + } + + if (camera) { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + } + + if (camera2D) { + camera2D.aspect = window.innerWidth / window.innerHeight; + camera2D.updateProjectionMatrix(); + } +} + +export function showFallbackCanvas(renderer: any): HTMLElement { + const canvas = renderer.domElement; + canvas.style.position = 'relative'; + styleFallbackCanvas(canvas); + canvas.style.display = ''; + return canvas; +} + +function bindWebGlContextEvents(renderer: any, handlers: RendererContextHandlers): void { + const gl = renderer.getContext(); + if (!gl) { + throw new Error('Failed to get WebGL context from renderer.'); + } + + gl.canvas.addEventListener('webglcontextlost', (event: Event) => { + event.preventDefault(); + console.error('CONTEXT_EVENT: WebGL Context Lost! xrSession active?', handlers.hasActiveXrSession(), event); + if (handlers.hasActiveXrSession()) { + handlers.closeActiveXrSession(); + } + }, false); + + gl.canvas.addEventListener('webglcontextrestored', () => { + console.log('CONTEXT_EVENT: WebGL Context Restored.'); + handlers.restoreAfterContextRestored(); + }, false); +} + +function getFallbackCanvasSize(playerContainer: HTMLElement): { height: number; width: number } { + const containerRect = playerContainer.getBoundingClientRect(); + const containerWidth = containerRect.width; + const calculatedHeight = containerWidth / FALLBACK_ASPECT_RATIO; + + return { + height: Math.max(calculatedHeight, MIN_FALLBACK_CANVAS_HEIGHT), + width: Math.max(containerWidth, MIN_FALLBACK_CANVAS_WIDTH) + }; +} + +function styleFallbackCanvas(canvas: HTMLElement): void { + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = '16/9'; +} diff --git a/src/vr180player/two-d-mode.ts b/src/vr180player/two-d-mode.ts new file mode 100644 index 0000000..507b565 --- /dev/null +++ b/src/vr180player/two-d-mode.ts @@ -0,0 +1,265 @@ +import type { ProjectionMode } from './config.js'; +import type { FallbackCameraControls } from './fallback-camera-controls.js'; +import { + hideRendererCanvas, + resizeFallbackRenderer, + showFallbackCanvas +} from './renderer-lifecycle.js'; +import { TwoDControlPanel } from './two-d-control-panel.js'; + +type TwoDModeCallbacks = { + createMediaTexture: () => any; + forward: () => void; + positionPlaneForPresentation: (isFallback2D?: boolean) => void; + rewind: () => void; + seekToProgress: (progress: number) => void; + showActiveContentMesh: () => void; + toggleMute: () => void; + togglePlayPause: () => void; +}; + +type TwoDModeOptions = { + callbacks: TwoDModeCallbacks; + fullscreenTarget: HTMLElement; + getActiveContentMesh: () => any; + getCamera: () => any; + getCameraControls: () => FallbackCameraControls | undefined; + getMaterial: () => any; + getRenderer: () => any; + getScene: () => any; + getVideo: () => HTMLVideoElement | undefined; + playerContainer: HTMLElement; + projectionMode: ProjectionMode; + title: string; +}; + +const FULLSCREEN_RESIZE_DELAY = 100; + +export class TwoDMode { + private readonly callbacks: TwoDModeCallbacks; + private readonly controls: TwoDControlPanel; + private readonly fullscreenTarget: HTMLElement; + private readonly getActiveContentMesh: () => any; + private readonly getCamera: () => any; + private readonly getCameraControls: () => FallbackCameraControls | undefined; + private readonly getMaterial: () => any; + private readonly getRenderer: () => any; + private readonly getScene: () => any; + private readonly getVideo: () => HTMLVideoElement | undefined; + private readonly playerContainer: HTMLElement; + private readonly projectionMode: ProjectionMode; + private active = false; + + constructor(options: TwoDModeOptions) { + this.callbacks = options.callbacks; + this.fullscreenTarget = options.fullscreenTarget; + this.getActiveContentMesh = options.getActiveContentMesh; + this.getCamera = options.getCamera; + this.getCameraControls = options.getCameraControls; + this.getMaterial = options.getMaterial; + this.getRenderer = options.getRenderer; + this.getScene = options.getScene; + this.getVideo = options.getVideo; + this.playerContainer = options.playerContainer; + this.projectionMode = options.projectionMode; + + this.controls = new TwoDControlPanel({ + callbacks: { + onForward: () => { + this.callbacks.forward(); + }, + onMute: () => { + this.callbacks.toggleMute(); + }, + onPlayPause: this.callbacks.togglePlayPause, + onRewind: () => { + this.callbacks.rewind(); + }, + onSeek: (progress) => { + this.callbacks.seekToProgress(progress); + } + }, + fullscreenTarget: this.fullscreenTarget, + getIsActive: () => this.active, + playerContainer: this.playerContainer, + title: options.title + }); + } + + get isActive(): boolean { + return this.active; + } + + start(): void { + const video = this.getVideo(); + const renderer = this.getRenderer(); + const camera = this.getCamera(); + + if (!video || !renderer || !camera) { + console.error("Required components not available for 2D mode"); + return; + } + + this.active = true; + this.resizeCanvasFor2D(renderer, camera); + + const canvas = showFallbackCanvas(renderer); + video.style.display = 'none'; + + const mediaTexture = this.callbacks.createMediaTexture(); + this.callbacks.positionPlaneForPresentation(this.projectionMode === 'plane'); + + const material = this.getMaterial(); + const activeContentMesh = this.getActiveContentMesh(); + if (material && activeContentMesh) { + material.map = mediaTexture; + material.needsUpdate = true; + this.callbacks.showActiveContentMesh(); + } + + this.callbacks.togglePlayPause(); + this.addEventListeners(canvas); + this.controls.show(); + this.positionControls(); + this.render(); + } + + stop(): void { + this.active = false; + + const renderer = this.getRenderer(); + if (renderer?.domElement) { + this.removeEventListeners(renderer.domElement); + hideRendererCanvas(renderer); + } else { + this.removeFullscreenEventListeners(); + } + + this.controls.hide(); + this.getCameraControls()?.reset(); + this.callbacks.positionPlaneForPresentation(false); + + const video = this.getVideo(); + if (video) { + video.style.display = ''; + } + } + + resize(): boolean { + if (!this.active) { + return false; + } + + const renderer = this.getRenderer(); + const camera = this.getCamera(); + if (renderer && camera) { + this.resizeCanvasFor2D(renderer, camera); + this.positionControls(); + } + return true; + } + + showControls(): void { + this.controls.show(); + } + + hideControls(): void { + this.controls.hide(); + } + + updateTimeline(): void { + if (!this.active) return; + + const video = this.getVideo(); + if (video) { + this.controls.updateTime(video.currentTime, video.duration); + } + } + + updatePlaybackButton(): void { + if (!this.active) return; + + const video = this.getVideo(); + if (video) { + this.controls.updatePlaybackButton(video.paused || video.ended); + } + } + + updateMuteButton(): void { + if (!this.active) return; + + const video = this.getVideo(); + if (video) { + this.controls.updateMuteButton(video.muted); + } + } + + handleVideoEnd(): void { + if (!this.active) return; + + this.controls.showPersistent(); + this.updatePlaybackButton(); + } + + private addEventListeners(canvas: HTMLElement): void { + this.getCameraControls()?.addEventListeners(canvas, this.projectionMode); + document.addEventListener('fullscreenchange', this.onFullscreenChange); + document.addEventListener('webkitfullscreenchange', this.onFullscreenChange); + document.addEventListener('mozfullscreenchange', this.onFullscreenChange); + document.addEventListener('MSFullscreenChange', this.onFullscreenChange); + } + + private removeEventListeners(canvas: HTMLElement): void { + this.getCameraControls()?.removeEventListeners(canvas); + this.removeFullscreenEventListeners(); + } + + private removeFullscreenEventListeners(): void { + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + document.removeEventListener('webkitfullscreenchange', this.onFullscreenChange); + document.removeEventListener('mozfullscreenchange', this.onFullscreenChange); + document.removeEventListener('MSFullscreenChange', this.onFullscreenChange); + } + + private readonly onFullscreenChange = (): void => { + if (!this.active) return; + + window.setTimeout(() => { + this.resize(); + }, FULLSCREEN_RESIZE_DELAY); + }; + + positionControls(): void { + const renderer = this.getRenderer(); + if (renderer?.domElement) { + this.controls.position(renderer.domElement); + } + } + + private readonly render = (): void => { + if (!this.active) return; + + const camera = this.getCamera(); + if (this.projectionMode === 'vr180') { + this.getCameraControls()?.updateCameraRotation(); + } else if (camera) { + camera.rotation.set(0, 0, 0); + } + + const renderer = this.getRenderer(); + const scene = this.getScene(); + if (renderer && camera && scene) { + renderer.render(scene, camera); + } + + requestAnimationFrame(this.render); + }; + + private resizeCanvasFor2D(renderer: any, camera: any): void { + resizeFallbackRenderer({ + camera2D: camera, + playerContainer: this.playerContainer, + renderer + }); + } +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index 5030538..83718f7 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -1,4 +1,3 @@ -import * as THREE from 'https://unpkg.com/three/build/three.module.js'; import { DEFAULT_PROJECTION, PLANE_2D_DISTANCE, @@ -17,6 +16,7 @@ import { } 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 @@ -30,7 +30,11 @@ import { updateVrVolumeButtonIcon } from './vr-control-panel.js'; import { VrPanelVisibility } from './vr-panel-visibility.js'; -import { TwoDControlPanel } from './two-d-control-panel.js'; +import { TwoDMode } from './two-d-mode.js'; +import { + createPlayerRenderer, + resizePlayerRenderer +} from './renderer-lifecycle.js'; const _playerBase = new URL('.', import.meta.url).href; @@ -43,10 +47,10 @@ let videoElement, playBtn; let frameCounter = 0; let isXrLoopActive = false; -let is2DMode = false; let vrControlPanel; +let mediaController: MediaController | undefined; let vrPanel: VrControlPanel | undefined; -let twoDControls: TwoDControlPanel | undefined; +let twoDMode: TwoDMode | undefined; const vrPanelVisibility = new VrPanelVisibility(); // 2D Camera Controls @@ -130,7 +134,7 @@ document.addEventListener('DOMContentLoaded', () => { }); function applySbsTextureWindow(renderingRenderer, activeCamera, material) { - applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DMode); + applySbsTextureWindowCore(renderingRenderer, activeCamera, material, is2DModeActive()); } function hideContentMeshes() { @@ -151,6 +155,34 @@ function createVideoTexture() { 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, ' ') || @@ -160,53 +192,22 @@ function getVideoTitle() { function init() { try { - scene = new THREE.Scene(); - camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); - camera.position.set(0, 1.6, 0.1); - scene.add(camera); - - renderer = new THREE.WebGLRenderer({ antialias: true }); - if (!renderer || !renderer.isWebGLRenderer) { - throw new Error("Failed to create WebGLRenderer or it's not a valid Three.js renderer type."); - } - renderer.setSize(window.innerWidth, window.innerHeight); - renderer.xr.enabled = true; - renderer.outputColorSpace = THREE.SRGBColorSpace; - playerContainer.appendChild(renderer.domElement); - - if (renderer.domElement) { - renderer.domElement.style.display = 'none'; - } - - const gl = renderer.getContext(); - if (!gl) { - throw new Error("Failed to get WebGL context from renderer."); - } - gl.canvas.addEventListener('webglcontextlost', (event) => { - event.preventDefault(); - console.error("CONTEXT_EVENT: WebGL Context Lost! xrSession active?", !!xrSession, event); - if (xrSession) { - const sessionToClose = xrSession; - xrSession = null; // Nullify global ref immediately - sessionToClose.removeEventListener('end', onVRSessionEnd); // Try to remove listener - sessionToClose.end().catch(e => { console.error("Error ending session on context lost:", e); }).finally(() => { - onVRSessionEnd({ session: sessionToClose }); - }); - } - }, false); - gl.canvas.addEventListener('webglcontextrestored', (event) => { - console.log("CONTEXT_EVENT: WebGL Context Restored."); - 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."); - } - }, false); + 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); }); @@ -219,9 +220,32 @@ function init() { camera2D = contentScene.fallbackCamera; fallbackCameraControls = new FallbackCameraControls(camera2D, { hideControls: hide2DControlPanel, - isEnabled: () => is2DMode, + 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; @@ -264,9 +288,6 @@ function init() { video }); } - - // Initialize 2D control panel - init2DControlPanel(); } catch (e) { console.error("INIT_ERROR (Phase 3 - Event Listeners):", e); } @@ -308,248 +329,81 @@ function hidePanel() { function onWindowResize() { if (!renderer) return; - if (renderer.xr && renderer.xr.isPresenting) return; - - if (is2DMode) { - // In 2D mode, calculate canvas size based on container dimensions - const container = playerContainer; - if (container) { - const containerRect = container.getBoundingClientRect(); - const containerWidth = containerRect.width; - - // Calculate height based on 16:9 aspect ratio - const aspectRatio = 16 / 9; - const calculatedHeight = containerWidth / aspectRatio; - - // Ensure minimum dimensions to prevent zero-sized canvas - const canvasWidth = Math.max(containerWidth, 320); - const canvasHeight = Math.max(calculatedHeight, 180); - - renderer.setSize(canvasWidth, canvasHeight); - camera2D.aspect = canvasWidth / canvasHeight; - camera2D.updateProjectionMatrix(); - - // Update canvas styling to maintain proper positioning - const canvas = renderer.domElement; - canvas.style.width = '100%'; - canvas.style.height = 'auto'; - canvas.style.aspectRatio = '16/9'; - - // Reposition control panel after resize - position2DControlPanel(); - } - } else { - // Normal VR/window mode - if (camera && renderer.domElement.style.display !== 'none') { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); - } else if (camera) { - camera.aspect = window.innerWidth / window.innerHeight; - camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); - } - - // Update 2D camera aspect ratio for potential future use - if (camera2D) { - camera2D.aspect = window.innerWidth / window.innerHeight; - camera2D.updateProjectionMatrix(); - } - } -} -// 2D Render Loop -function render2D() { - if (!is2DMode) return; - - if (projectionMode === 'vr180') { - fallbackCameraControls?.updateCameraRotation(); - } else if (camera2D) { - camera2D.rotation.set(0, 0, 0); - } - - if (renderer && camera2D && scene) { - renderer.render(scene, camera2D); - } - - requestAnimationFrame(render2D); -} + if (twoDMode?.resize()) return; -// 2D Control Panel Functions -function init2DControlPanel() { - twoDControls = new TwoDControlPanel({ - callbacks: { - onForward: () => { - if (video && isFinite(video.duration)) { - video.currentTime = Math.min(video.duration, video.currentTime + 15); - } - }, - onMute: () => { - if (video) { - video.muted = !video.muted; - } - }, - onPlayPause: togglePlayPause, - onRewind: () => { - if (video) { - video.currentTime = Math.max(0, video.currentTime - 15); - } - }, - onSeek: (progress) => { - if (video && isFinite(video.duration)) { - video.currentTime = progress * video.duration; - } - } - }, - fullscreenTarget: playerContainer, - getIsActive: () => is2DMode, + resizePlayerRenderer({ + camera, + camera2D, + is2DMode: false, + onFallbackResize: () => {}, playerContainer, - title: getVideoTitle() + renderer }); } function show2DControlPanel() { - twoDControls?.show(); + twoDMode?.showControls(); } function hide2DControlPanel() { - twoDControls?.hide(); + twoDMode?.hideControls(); } function update2DControlPanel() { - if (!is2DMode || !video) return; - - twoDControls?.updateTime(video.currentTime, video.duration); + twoDMode?.updateTimeline(); } function update2DPlayPauseButton() { - if (!is2DMode || !video) return; - - twoDControls?.updatePlaybackButton(video.paused || video.ended); + twoDMode?.updatePlaybackButton(); } function update2DMuteButton() { - if (!is2DMode || !video) return; - - twoDControls?.updateMuteButton(video.muted); + twoDMode?.updateMuteButton(); } function handle2DVideoEnd() { - if (!is2DMode || !video) return; - - twoDControls?.showPersistent(); - update2DPlayPauseButton(); -} - -function position2DControlPanel() { - if (!renderer) return; - - twoDControls?.position(renderer.domElement); -} - -function hidePlayButton() { - if (playBtn) { - playBtn.classList.add('hidden'); - } -} - -function enableNativeControls() { - if (video) { - video.controls = true; - } -} - -function togglePlayPause() { - if (!video || !video.currentSrc) return; - if (video.paused || video.ended) { - // If video has ended in 2D mode, restart from beginning - if (video.ended && is2DMode) { - video.currentTime = 0; - } - - if (video.readyState >= video.HAVE_ENOUGH_DATA || video.currentSrc) { - const playPromise = video.play(); - if (playPromise !== undefined) { - playPromise.then(() => { - // Resume normal control panel auto-hide behavior after restart - if (is2DMode && video.ended === false) { - show2DControlPanel(); - } - }).catch(err => console.error("Error during video.play():", err)); - } else { - console.error("video.play() did not return a promise."); - } - } - } else { - video.pause(); - } + twoDMode?.handleVideoEnd(); } function resetToOriginalState() { - // Reset video to show poster frame - if (video) { - video.pause(); - video.currentTime = 0; - video.controls = false; // Disable native controls - - // Force video back to poster state by reloading - video.load(); - } - - // Show the play button in center position - if (playBtn) { + if (mediaController) { + mediaController.resetToOriginalState(); + } else if (playBtn) { playBtn.classList.remove('hidden'); playBtn.disabled = false; } - // Reset 2D mode if it was active - if (is2DMode) { - is2DMode = false; - remove2DEventListeners(); - - // Hide 2D control panel - hide2DControlPanel(); - - // Reset camera rotation - fallbackCameraControls?.reset(); - positionPlaneForPresentation(false); - - // Hide WebGL canvas and show video element - if (renderer && renderer.domElement) { - renderer.domElement.style.display = 'none'; - } - if (video) { - video.style.display = ''; - } - - // Reset renderer size + if (twoDMode?.isActive) { + twoDMode.stop(); onWindowResize(); } } function onVideoEnded() { - if (video && !video.paused) video.pause(); - - if (xrSession && renderer && renderer.xr.isPresenting) { - // VR mode - exit VR and reset to original state - actualSessionToggle().catch(err => { - console.error("Error during automatic VR exit on video end:", err); - // Fallback cleanup if actualSessionToggle fails or doesn't fully clean up - if(xrSession) { // Check if session still exists - const sessionToClean = xrSession; - xrSession = null; // Nullify global ref - sessionToClean.removeEventListener('end', onVRSessionEnd); - sessionToClean.end().catch(e => {}).finally(() => onVRSessionEnd({session: sessionToClean})); - } else { - onVRSessionEnd({session: null}); // Call with null session if already gone - } - }); - } else if (is2DMode) { - // 2D mode - stay on last frame with controls visible - handle2DVideoEnd(); - } else { - // Regular mode - reset to original state + 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}); } } @@ -559,31 +413,27 @@ function onSelectStartVR(event) { if (xrSession) actualSessionToggle(); }, forward: () => { - if (video && isFinite(video.duration)) { - video.currentTime = Math.min(video.duration, video.currentTime + 15); - updateSeekBarAppearance(); - } + mediaController?.forward(); + updateSeekBarAppearance(); }, hidePanel, isPanelVisible: () => vrPanelVisibility.isVisible, raycaster, rewind: () => { - if (video) { - video.currentTime = Math.max(0, video.currentTime - 15); - updateSeekBarAppearance(); - } + mediaController?.rewind(); + updateSeekBarAppearance(); }, seek: (progress) => { - if (video && isFinite(video.duration)) { - video.currentTime = progress * video.duration; - updateSeekBarAppearance(); - } + mediaController?.seekToProgress(progress); + updateSeekBarAppearance(); }, showPanel, toggleMute: () => { - if (video) video.muted = !video.muted; + mediaController?.toggleMute(); + }, + togglePlayPause: () => { + mediaController?.togglePlayPause(); }, - togglePlayPause, uiElements, vrPanel }); @@ -596,7 +446,7 @@ async function handleEnterVRButtonClick() { } // Hide the play button after click - hidePlayButton(); + mediaController?.hidePlayButton(); // Check if VR is supported if (playBtn.dataset.xrSupported === "true") { @@ -604,110 +454,10 @@ async function handleEnterVRButtonClick() { await actualSessionToggle(); } else { // VR is not supported - start 2D rectilinear mode - start2DMode(); + twoDMode?.start(); } } -function start2DMode() { - if (!video || !renderer || !camera2D) { - console.error("Required components not available for 2D mode"); - return; - } - - // Set 2D mode flag - is2DMode = true; - - // Calculate canvas size based on container dimensions (same logic as onWindowResize) - const container = playerContainer; - if (container) { - const containerRect = container.getBoundingClientRect(); - const containerWidth = containerRect.width; - - // Calculate height based on 16:9 aspect ratio - const aspectRatio = 16 / 9; - const calculatedHeight = containerWidth / aspectRatio; - - // Ensure minimum dimensions to prevent zero-sized canvas - const canvasWidth = Math.max(containerWidth, 320); - const canvasHeight = Math.max(calculatedHeight, 180); - - // Resize renderer with calculated dimensions - renderer.setSize(canvasWidth, canvasHeight); - - // Update 2D camera aspect ratio - camera2D.aspect = canvasWidth / canvasHeight; - camera2D.updateProjectionMatrix(); - } - - // Position the canvas to match the video element - const canvas = renderer.domElement; - canvas.style.position = 'relative'; - canvas.style.width = '100%'; - canvas.style.height = 'auto'; - canvas.style.aspectRatio = '16/9'; - - // Hide HTML video element and show WebGL canvas - video.style.display = 'none'; - canvas.style.display = ''; - - // Create video texture if not exists - videoTexture = createVideoTexture(); - positionPlaneForPresentation(projectionMode === 'plane'); - - // Apply texture to the selected projection mesh and make it visible - if (sphereMaterial && activeContentMesh) { - sphereMaterial.map = videoTexture; - sphereMaterial.needsUpdate = true; - showActiveContentMesh(); - } - - // Start video playback - togglePlayPause(); - - // Add event listeners for 2D controls - add2DEventListeners(); - - // Add fullscreen event listeners to handle resize properly - document.addEventListener('fullscreenchange', onFullscreenChange); - document.addEventListener('webkitfullscreenchange', onFullscreenChange); - document.addEventListener('mozfullscreenchange', onFullscreenChange); - document.addEventListener('MSFullscreenChange', onFullscreenChange); - - // Show 2D control panel - show2DControlPanel(); - - // Position control panel relative to canvas - position2DControlPanel(); - - // Start 2D render loop - render2D(); -} - -function add2DEventListeners() { - fallbackCameraControls?.addEventListeners(renderer.domElement, projectionMode); -} - -function remove2DEventListeners() { - if (!renderer || !renderer.domElement) return; - - fallbackCameraControls?.removeEventListeners(renderer.domElement); - - // Fullscreen events - document.removeEventListener('fullscreenchange', onFullscreenChange); - document.removeEventListener('webkitfullscreenchange', onFullscreenChange); - document.removeEventListener('mozfullscreenchange', onFullscreenChange); - document.removeEventListener('MSFullscreenChange', onFullscreenChange); -} - -function onFullscreenChange() { - if (!is2DMode) return; - - // Trigger resize handling when fullscreen state changes - setTimeout(() => { - onWindowResize(); - }, 100); // Small delay to ensure fullscreen transition is complete -} - async function actualSessionToggle() { if (!renderer || !renderer.isWebGLRenderer) { console.error("CRITICAL_ERROR: actualSessionToggle: renderer is NOT a WebGLRenderer or is null!", renderer); @@ -741,9 +491,9 @@ async function actualSessionToggle() { video.style.display = 'none'; } - if (video && (video.paused || video.ended)) { + if (mediaController && video && (video.paused || video.ended)) { try { - await video.play(); + await mediaController.play(); } catch (playError) { console.error("Failed to play video after obtaining XR session:", playError); } @@ -818,9 +568,7 @@ function onVRSessionEnd(event) { video.style.display = ''; } - if (video && !video.paused) { - video.pause(); - } + mediaController?.pauseIfPlaying(); if (sphereMaterial && sphereMaterial.map) { sphereMaterial.map.dispose();