diff --git a/src/vr180player/content-scene.ts b/src/vr180player/content-scene.ts new file mode 100644 index 0000000..1e75a02 --- /dev/null +++ b/src/vr180player/content-scene.ts @@ -0,0 +1,69 @@ +import * as THREE from 'https://unpkg.com/three/build/three.module.js'; +import { + PLANE_DISTANCE, + PLANE_HEIGHT, + PLANE_WIDTH, + type ProjectionMode +} from './config.js'; + +type ContentBeforeRender = ( + renderer: any, + scene: any, + activeCamera: any, + geometry: any, + material: any, + group: any +) => void; + +export type ContentScene = { + activeContentMesh: any; + fallbackCamera: any; + material: any; + planeMesh: any; + vr180Mesh: any; +}; + +export function createContentScene( + scene: any, + projectionMode: ProjectionMode, + onBeforeRender: ContentBeforeRender +): ContentScene { + const sphereGeometry = new THREE.SphereGeometry( + 500, + 64, + 32, + -Math.PI / 2, + Math.PI, + 0, + Math.PI + ); + sphereGeometry.scale(-1, 1, 1); + + const material = new THREE.MeshBasicMaterial({ map: null }); + const vr180Mesh = new THREE.Mesh(sphereGeometry, material); + vr180Mesh.name = 'vr180Mesh'; + vr180Mesh.rotation.y = Math.PI / 2; + vr180Mesh.visible = false; + vr180Mesh.onBeforeRender = onBeforeRender; + scene.add(vr180Mesh); + + const planeGeometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT); + const planeMesh = new THREE.Mesh(planeGeometry, material); + planeMesh.name = 'vrSbsPlaneMesh'; + planeMesh.position.set(0, 1.6, -PLANE_DISTANCE); + planeMesh.visible = false; + planeMesh.onBeforeRender = onBeforeRender; + scene.add(planeMesh); + + const fallbackCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); + fallbackCamera.position.set(0, 1.6, 0.1); + fallbackCamera.rotation.set(0, 0, 0); + + return { + activeContentMesh: projectionMode === 'plane' ? planeMesh : vr180Mesh, + fallbackCamera, + material, + planeMesh, + vr180Mesh + }; +} diff --git a/src/vr180player/two-d-control-panel.ts b/src/vr180player/two-d-control-panel.ts new file mode 100644 index 0000000..1e75ed4 --- /dev/null +++ b/src/vr180player/two-d-control-panel.ts @@ -0,0 +1,200 @@ +import { setLucideIcon } from './icons.js'; +import { formatTime } from './time.js'; + +type TwoDControlPanelCallbacks = { + onForward: () => void; + onMute: () => void; + onPlayPause: () => void; + onRewind: () => void; + onSeek: (progress: number) => void; +}; + +type TwoDControlPanelOptions = { + callbacks: TwoDControlPanelCallbacks; + fullscreenTarget: HTMLElement; + getIsActive: () => boolean; + playerContainer: HTMLElement; + title: string; +}; + +const CONTROL_PANEL_HIDE_DELAY = 3000; + +export class TwoDControlPanel { + private readonly callbacks: TwoDControlPanelCallbacks; + private readonly fullscreenTarget: HTMLElement; + private readonly getIsActive: () => boolean; + private readonly playerContainer: HTMLElement; + private controlPanel: HTMLElement | null; + private currentTimeDisplay: HTMLElement | null; + private hideTimeout: number | undefined; + private playedBar: HTMLElement | null; + private progressBar: HTMLElement | null; + private totalTimeDisplay: HTMLElement | null; + private playButton: HTMLButtonElement | null; + private muteButton: HTMLButtonElement | null; + + constructor({ callbacks, fullscreenTarget, getIsActive, playerContainer, title }: TwoDControlPanelOptions) { + this.callbacks = callbacks; + this.fullscreenTarget = fullscreenTarget; + this.getIsActive = getIsActive; + this.playerContainer = playerContainer; + + this.controlPanel = playerContainer.querySelector('.vrwp-panel'); + const videoTitle = playerContainer.querySelector('.vrwp-video-title'); + this.currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time'); + this.totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time'); + this.progressBar = playerContainer.querySelector('.vrwp-bar'); + this.playedBar = playerContainer.querySelector('.vrwp-played'); + this.playButton = playerContainer.querySelector('.vrwp-play-toggle'); + this.muteButton = playerContainer.querySelector('.vrwp-mute'); + + if (!this.controlPanel) { + console.error('VR_WEB_PLAYER_DOM: 2D control panel was not found.'); + return; + } + + if (videoTitle) { + videoTitle.textContent = title; + } + + this.bindControls(playerContainer); + } + + show(): void { + if (!this.getIsActive() || !this.controlPanel) return; + + this.clearHideTimeout(); + this.controlPanel.classList.add('visible'); + this.hideTimeout = window.setTimeout(() => this.hide(), CONTROL_PANEL_HIDE_DELAY); + } + + showPersistent(): void { + if (!this.getIsActive() || !this.controlPanel) return; + + this.clearHideTimeout(); + this.controlPanel.classList.add('visible'); + } + + hide(): void { + if (!this.controlPanel) return; + + this.clearHideTimeout(); + this.controlPanel.classList.remove('visible'); + } + + position(canvas: HTMLElement): void { + if (!this.getIsActive() || !this.controlPanel) return; + + const canvasRect = canvas.getBoundingClientRect(); + const containerRect = this.playerContainer.getBoundingClientRect(); + const bottomOffset = canvasRect.height * 0.1; + const panelHeight = this.controlPanel.offsetHeight; + const topPosition = (canvasRect.bottom - containerRect.top) - bottomOffset - panelHeight; + + this.controlPanel.style.position = 'absolute'; + this.controlPanel.style.top = `${topPosition}px`; + this.controlPanel.style.bottom = 'auto'; + this.controlPanel.style.left = '50%'; + this.controlPanel.style.transform = 'translateX(-50%)'; + this.controlPanel.style.zIndex = '1000'; + } + + updateMuteButton(isMuted: boolean): void { + if (!this.getIsActive() || !this.muteButton) return; + + if (isMuted) { + this.muteButton.classList.remove('muted'); + this.muteButton.classList.add('unmuted'); + setLucideIcon(this.muteButton, 'volume-x'); + } else { + this.muteButton.classList.remove('unmuted'); + this.muteButton.classList.add('muted'); + setLucideIcon(this.muteButton, 'volume-2'); + } + } + + updatePlaybackButton(isPausedOrEnded: boolean): void { + if (!this.getIsActive() || !this.playButton) return; + + if (isPausedOrEnded) { + this.playButton.classList.remove('playing'); + this.playButton.classList.add('paused'); + setLucideIcon(this.playButton, 'play'); + } else { + this.playButton.classList.remove('paused'); + this.playButton.classList.add('playing'); + setLucideIcon(this.playButton, 'pause'); + } + } + + updateTime(currentTime: number, duration: number): void { + if (!this.getIsActive()) return; + + if (this.currentTimeDisplay) { + this.currentTimeDisplay.textContent = formatTime(currentTime); + } + + if (this.totalTimeDisplay && isFinite(duration)) { + this.totalTimeDisplay.textContent = formatTime(duration); + } + + if (this.playedBar && isFinite(duration) && duration > 0) { + const progress = (currentTime / duration) * 100; + this.playedBar.style.width = `${progress}%`; + } + } + + private bindControls(playerContainer: HTMLElement): void { + playerContainer.querySelector('.vrwp-fullscreen')?.addEventListener('click', () => { + this.toggleFullscreen(); + }); + + playerContainer.querySelector('.vrwp-back')?.addEventListener('click', () => { + this.callbacks.onRewind(); + this.show(); + }); + + this.playButton?.addEventListener('click', () => { + this.callbacks.onPlayPause(); + this.show(); + }); + + playerContainer.querySelector('.vrwp-forward')?.addEventListener('click', () => { + this.callbacks.onForward(); + this.show(); + }); + + this.muteButton?.addEventListener('click', () => { + this.callbacks.onMute(); + this.show(); + }); + + this.progressBar?.addEventListener('click', (event) => { + const rect = this.progressBar?.getBoundingClientRect(); + if (rect && rect.width > 0) { + this.callbacks.onSeek((event.clientX - rect.left) / rect.width); + } + this.show(); + }); + } + + private clearHideTimeout(): void { + if (this.hideTimeout !== undefined) { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + } + + private toggleFullscreen(): void { + if (!document.fullscreenElement) { + this.fullscreenTarget.requestFullscreen().catch((err) => { + console.error('Error attempting to enable fullscreen:', err); + }); + return; + } + + document.exitFullscreen().catch((err) => { + console.error('Error attempting to exit fullscreen:', err); + }); + } +} diff --git a/src/vr180player/video-events.ts b/src/vr180player/video-events.ts new file mode 100644 index 0000000..465c2cc --- /dev/null +++ b/src/vr180player/video-events.ts @@ -0,0 +1,54 @@ +type VideoEventCallbacks = { + onEnded: () => void; + onPlaybackStateChange: () => void; + onTimelineChange: () => void; + onVolumeChange: () => void; +}; + +type BindVideoEventsOptions = VideoEventCallbacks & { + playButton: HTMLButtonElement | undefined; + video: HTMLVideoElement; +}; + +export function bindVideoEvents({ + onEnded, + onPlaybackStateChange, + onTimelineChange, + onVolumeChange, + playButton, + video +}: BindVideoEventsOptions): void { + video.onloadedmetadata = () => { + if (isFinite(video.duration) && playButton) { + playButton.disabled = false; + } + + onTimelineChange(); + onPlaybackStateChange(); + onVolumeChange(); + }; + + video.oncanplaythrough = () => { + if (playButton && video.readyState >= video.HAVE_FUTURE_DATA) { + playButton.disabled = false; + } + }; + + video.ontimeupdate = () => { + if (isFinite(video.duration)) { + onTimelineChange(); + } + }; + + video.onplaying = onPlaybackStateChange; + video.onpause = onPlaybackStateChange; + video.onerror = (event) => { + const videoError = video.error; + const errorDetail = videoError ? `Code: ${videoError.code}, Message: ${videoError.message}` : 'Unknown error'; + console.error('VIDEO_ERROR_EVENT:', event, 'Details:', errorDetail); + if (playButton) playButton.disabled = true; + }; + + video.addEventListener('ended', onEnded); + video.addEventListener('volumechange', onVolumeChange); +} diff --git a/src/vr180player/vr-controller-interactions.ts b/src/vr180player/vr-controller-interactions.ts new file mode 100644 index 0000000..9c645f6 --- /dev/null +++ b/src/vr180player/vr-controller-interactions.ts @@ -0,0 +1,109 @@ +import * as THREE from 'https://unpkg.com/three/build/three.module.js'; +import { + getSeekProgressFromIntersection, + type VrControlPanel +} from './vr-control-panel.js'; + +type VrControllerSelectionOptions = { + exitVr: () => void; + forward: () => void; + hidePanel: () => void; + isPanelVisible: () => boolean; + raycaster: any; + rewind: () => void; + seek: (progress: number) => void; + showPanel: () => void; + toggleMute: () => void; + togglePlayPause: () => void; + uiElements: any[]; + vrPanel: VrControlPanel | undefined; +}; + +const tempMatrix = new THREE.Matrix4(); + +export function createVrController(scene: any, renderer: any, onSelectStart: (event: any) => void): { + controller: any; + raycaster: any; +} { + const controller = renderer.xr.getController(0); + controller.addEventListener('selectstart', onSelectStart); + + const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }); + const lineGeometry = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, -5) + ]); + controller.add(new THREE.Line(lineGeometry, lineMaterial)); + scene.add(controller); + + const raycaster = new THREE.Raycaster(); + raycaster.near = 0.1; + raycaster.far = 5; + + return { controller, raycaster }; +} + +export function handleVrControllerSelect(event: any, options: VrControllerSelectionOptions): void { + const controller = event.target; + if (!options.raycaster) return; + + controller.updateMatrixWorld(); + tempMatrix.identity().extractRotation(controller.matrixWorld); + options.raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); + options.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); + + const directIntersects = options.raycaster.intersectObjects(options.uiElements, true); + if (directIntersects.length === 0) { + togglePanel(options); + return; + } + + const firstIntersected = directIntersects[0].object; + const intersectionPoint = directIntersects[0].point; + + if (firstIntersected.name === 'vrPlayPauseButton') { + options.togglePlayPause(); + options.showPanel(); + return; + } + + if (firstIntersected.name === 'vrRewindButton') { + options.rewind(); + options.showPanel(); + return; + } + + if (firstIntersected.name === 'vrForwardButton') { + options.forward(); + options.showPanel(); + return; + } + + if (firstIntersected.name === 'vrExitButton') { + options.exitVr(); + options.showPanel(); + return; + } + + if (firstIntersected.name === 'vrVolumeButton') { + options.toggleMute(); + options.showPanel(); + return; + } + + if (firstIntersected.name === 'seekBarHitArea') { + options.showPanel(); + options.seek(getSeekProgressFromIntersection(options.vrPanel, intersectionPoint)); + return; + } + + togglePanel(options); +} + +function togglePanel(options: VrControllerSelectionOptions): void { + if (options.isPanelVisible()) { + options.hidePanel(); + } else { + options.showPanel(); + } +} diff --git a/src/vr180player/vr-panel-visibility.ts b/src/vr180player/vr-panel-visibility.ts new file mode 100644 index 0000000..037bb7c --- /dev/null +++ b/src/vr180player/vr-panel-visibility.ts @@ -0,0 +1,111 @@ +import { + hideVrPanelImmediately, + setVrPanelOpacity, + type VrControlPanel +} from './vr-control-panel.js'; + +const FADE_DURATION_MS = 200; +const AUTO_HIDE_DELAY_MS = 10000; + +export class VrPanelVisibility { + private hideTimeout: number | undefined; + private lastFadeTimestamp = 0; + private opacity = 0; + private panel: VrControlPanel | undefined; + private targetOpacity = 0; + private fading = false; + + get isFading(): boolean { + return this.fading; + } + + get isVisible(): boolean { + return !!(this.panel?.group.visible && this.opacity > 0.01); + } + + setPanel(panel: VrControlPanel): void { + this.panel = panel; + this.hideImmediately(); + } + + show(): void { + if (this.panel) this.panel.group.visible = true; + this.clearHideTimeout(); + + if (this.targetOpacity !== 1.0 || this.opacity < 1.0) { + this.targetOpacity = 1.0; + this.startFade(); + } + + this.hideTimeout = window.setTimeout(() => this.hide(), AUTO_HIDE_DELAY_MS); + } + + hide(): void { + this.clearHideTimeout(); + + if (this.targetOpacity !== 0.0 || this.opacity > 0.0) { + this.targetOpacity = 0.0; + this.startFade(); + } + } + + hideImmediately(): void { + this.clearHideTimeout(); + this.targetOpacity = 0; + this.opacity = 0; + this.fading = false; + hideVrPanelImmediately(this.panel); + } + + updateFade(timestamp: number): void { + if (!this.panel) return; + if (this.lastFadeTimestamp === 0) this.lastFadeTimestamp = timestamp; + + const deltaTime = (timestamp - this.lastFadeTimestamp) / 1000; + this.lastFadeTimestamp = timestamp; + const fadeSpeed = 1 / (FADE_DURATION_MS / 1000); + let opacityChanged = false; + + if (this.opacity < this.targetOpacity) { + this.opacity += fadeSpeed * deltaTime; + if (this.opacity >= this.targetOpacity) { + this.opacity = this.targetOpacity; + this.fading = false; + } + opacityChanged = true; + } else if (this.opacity > this.targetOpacity) { + this.opacity -= fadeSpeed * deltaTime; + if (this.opacity <= this.targetOpacity) { + this.opacity = this.targetOpacity; + this.fading = false; + if (this.opacity === 0) this.panel.group.visible = false; + } + opacityChanged = true; + } else { + this.fading = false; + } + + if (opacityChanged) { + setVrPanelOpacity(this.panel, this.opacity); + } + + if (this.fading) { + requestAnimationFrame((nextTimestamp) => this.updateFade(nextTimestamp)); + } + } + + private clearHideTimeout(): void { + if (this.hideTimeout !== undefined) { + clearTimeout(this.hideTimeout); + this.hideTimeout = undefined; + } + } + + private startFade(): void { + if (!this.fading) { + this.fading = true; + this.lastFadeTimestamp = 0; + requestAnimationFrame((timestamp) => this.updateFade(timestamp)); + } + } +} diff --git a/src/vr180player/vr180-player.ts b/src/vr180player/vr180-player.ts index b7057f8..5030538 100644 --- a/src/vr180player/vr180-player.ts +++ b/src/vr180player/vr180-player.ts @@ -3,12 +3,11 @@ import { DEFAULT_PROJECTION, PLANE_2D_DISTANCE, PLANE_DISTANCE, - PLANE_HEIGHT, - PLANE_WIDTH, 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, @@ -17,19 +16,21 @@ import { showActiveContentMesh as showActiveContentMeshCore } from './projection.js'; import { createVideoTexture as createVideoTextureCore } from './three-utils.js'; -import { setLucideIcon } from './icons.js'; import { FallbackCameraControls } from './fallback-camera-controls.js'; -import { formatTime } from './time.js'; +import { + createVrController, + handleVrControllerSelect +} from './vr-controller-interactions.js'; +import { bindVideoEvents } from './video-events.js'; import { createVrControlPanel, - getSeekProgressFromIntersection, - hideVrPanelImmediately, - setVrPanelOpacity, type VrControlPanel, updateVrPlayPauseButtonIcon, updateVrSeekBarAppearance, updateVrVolumeButtonIcon } from './vr-control-panel.js'; +import { VrPanelVisibility } from './vr-panel-visibility.js'; +import { TwoDControlPanel } from './two-d-control-panel.js'; const _playerBase = new URL('.', import.meta.url).href; @@ -37,36 +38,21 @@ let playerContainer, projectionMode: ProjectionMode = DEFAULT_PROJECTION; let scene, camera, renderer, video, videoTexture, sphereMaterial; let vr180Mesh, planeMesh, activeContentMesh; let xrSession = null; -let controller1, raycaster, uiElements = []; -const tempMatrix = new THREE.Matrix4(); +let raycaster, uiElements = []; let videoElement, playBtn; let frameCounter = 0; -// 2D Control Panel Elements -let controlPanel, videoTitle, currentTimeDisplay, totalTimeDisplay, progressBar, playedBar; -let fullscreenBtn, backBtn, play2Btn, forwardBtn, muteBtn; -let controlPanelTimeout; -let isControlPanelVisible = false; -const CONTROL_PANEL_HIDE_DELAY = 3000; // 3 seconds - let isXrLoopActive = false; let is2DMode = false; let vrControlPanel; let vrPanel: VrControlPanel | undefined; +let twoDControls: TwoDControlPanel | undefined; +const vrPanelVisibility = new VrPanelVisibility(); // 2D Camera Controls let camera2D; let fallbackCameraControls: FallbackCameraControls | undefined; -// Panel fade animation variables -let panelOpacity = 0; -let panelTargetOpacity = 0; -let isPanelFading = false; -let panelHideTimeout = null; -let lastFadeTimestamp = 0; -const FADE_DURATION_MS = 200; -const AUTO_HIDE_DELAY_MS = 10000; - injectPlayerStyles(_playerBase); document.addEventListener('DOMContentLoaded', () => { @@ -221,48 +207,16 @@ function init() { }, false); video = videoElement; - - const sphereRadius = 500; - let thetaStart = 0; - let thetaLength = Math.PI; - - const sphereGeometry = new THREE.SphereGeometry( - sphereRadius, 64, 32, - -Math.PI / 2, - Math.PI, - thetaStart, - thetaLength - ); - sphereGeometry.scale(-1, 1, 1); - sphereMaterial = new THREE.MeshBasicMaterial({ map: null }); - vr180Mesh = new THREE.Mesh(sphereGeometry, sphereMaterial); - vr180Mesh.name = "vr180Mesh"; - - vr180Mesh.rotation.y = Math.PI / 2; - scene.add(vr180Mesh); - vr180Mesh.visible = false; - - vr180Mesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) { + const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => { applySbsTextureWindow(renderer, activeCamera, material); - }; - - const planeGeometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT); - planeMesh = new THREE.Mesh(planeGeometry, sphereMaterial); - planeMesh.name = "vrSbsPlaneMesh"; - planeMesh.position.set(0, 1.6, -PLANE_DISTANCE); - planeMesh.onBeforeRender = function (renderer, scene, activeCamera, geometry, material, group) { - applySbsTextureWindow(renderer, activeCamera, material); - }; - scene.add(planeMesh); - planeMesh.visible = false; - - activeContentMesh = projectionMode === 'plane' ? planeMesh : vr180Mesh; + }); + sphereMaterial = contentScene.material; + vr180Mesh = contentScene.vr180Mesh; + planeMesh = contentScene.planeMesh; + activeContentMesh = contentScene.activeContentMesh; uiElements.push(activeContentMesh); - // Initialize 2D camera - camera2D = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); - camera2D.position.set(0, 1.6, 0.1); - camera2D.rotation.set(0, 0, 0); + camera2D = contentScene.fallbackCamera; fallbackCameraControls = new FallbackCameraControls(camera2D, { hideControls: hide2DControlPanel, isEnabled: () => is2DMode, @@ -277,19 +231,10 @@ function init() { try { // Phase 2: VR Control Panel UI vrPanel = createVrControlPanel(scene, getVideoTitle()); vrControlPanel = vrPanel.group; + vrPanelVisibility.setPanel(vrPanel); uiElements.push(...vrPanel.interactables); - panelOpacity = 0; - panelTargetOpacity = 0; - - controller1 = renderer.xr.getController(0); - controller1.addEventListener('selectstart', onSelectStartVR); - const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.5 }); - const lineGeometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -5)]); - controller1.add(new THREE.Line(lineGeometry, lineMaterial)); - scene.add(controller1); - raycaster = new THREE.Raycaster(); - raycaster.near = 0.1; raycaster.far = 5; + raycaster = createVrController(scene, renderer, onSelectStartVR).raycaster; } catch (e) { console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e); } @@ -301,46 +246,23 @@ function init() { window.addEventListener('resize', onWindowResize); if (video) { - video.onloadedmetadata = () => { - if (isFinite(video.duration) && playBtn) { - // Enable button for both VR and non-VR scenarios when video is ready - playBtn.disabled = false; - } - updateSeekBarAppearance(); - updateVRPlayPauseButtonIcon(); - updateVRVolumeButtonIcon(); - update2DControlPanel(); - update2DMuteButton(); - }; - video.oncanplaythrough = () => { - if (playBtn && video.readyState >= video.HAVE_FUTURE_DATA) { - // Enable button for both VR and non-VR scenarios when video is ready to play - playBtn.disabled = false; - } - }; - video.ontimeupdate = () => { - if (isFinite(video.duration)) { + bindVideoEvents({ + onEnded: onVideoEnded, + onPlaybackStateChange: () => { + updateVRPlayPauseButtonIcon(); + update2DPlayPauseButton(); + }, + onTimelineChange: () => { updateSeekBarAppearance(); update2DControlPanel(); - } - }; - video.onplaying = () => { - updateVRPlayPauseButtonIcon(); - update2DPlayPauseButton(); - }; - video.onpause = () => { - updateVRPlayPauseButtonIcon(); - update2DPlayPauseButton(); - }; - video.onerror = (e) => { - const videoError = video.error; - const errorDetail = videoError ? `Code: ${videoError.code}, Message: ${videoError.message}` : 'Unknown error'; - console.error("VIDEO_ERROR_EVENT:", e, "Details:", errorDetail); - if (playBtn) playBtn.disabled = true; - }; - video.addEventListener('ended', onVideoEnded); - video.addEventListener('volumechange', updateVRVolumeButtonIcon); - video.addEventListener('volumechange', update2DMuteButton); + }, + onVolumeChange: () => { + updateVRVolumeButtonIcon(); + update2DMuteButton(); + }, + playButton: playBtn, + video + }); } // Initialize 2D control panel @@ -373,60 +295,15 @@ function updateSeekBarAppearance() { } function animatePanelFade(timestamp) { - if (!vrControlPanel) return; - if (lastFadeTimestamp === 0) lastFadeTimestamp = timestamp; - const deltaTime = (timestamp - lastFadeTimestamp) / 1000; - lastFadeTimestamp = timestamp; - const FADE_SPEED = 1 / (FADE_DURATION_MS / 1000); - let opacityChanged = false; - if (panelOpacity < panelTargetOpacity) { - panelOpacity += FADE_SPEED * deltaTime; - if (panelOpacity >= panelTargetOpacity) { - panelOpacity = panelTargetOpacity; - isPanelFading = false; - } - opacityChanged = true; - } else if (panelOpacity > panelTargetOpacity) { - panelOpacity -= FADE_SPEED * deltaTime; - if (panelOpacity <= panelTargetOpacity) { - panelOpacity = panelTargetOpacity; - isPanelFading = false; - if (panelOpacity === 0) vrControlPanel.visible = false; - } - opacityChanged = true; - } else { - isPanelFading = false; - } - if (opacityChanged) { - setVrPanelOpacity(vrPanel, panelOpacity); - } - if (isPanelFading) requestAnimationFrame(animatePanelFade); + vrPanelVisibility.updateFade(timestamp); } function showPanel() { - if (vrControlPanel) vrControlPanel.visible = true; - clearTimeout(panelHideTimeout); - if (panelTargetOpacity !== 1.0 || panelOpacity < 1.0) { - panelTargetOpacity = 1.0; - if (!isPanelFading) { - isPanelFading = true; - lastFadeTimestamp = 0; - requestAnimationFrame(animatePanelFade); - } - } - panelHideTimeout = setTimeout(hidePanel, AUTO_HIDE_DELAY_MS); + vrPanelVisibility.show(); } function hidePanel() { - clearTimeout(panelHideTimeout); - if (panelTargetOpacity !== 0.0 || panelOpacity > 0.0) { - panelTargetOpacity = 0.0; - if (!isPanelFading) { - isPanelFading = true; - lastFadeTimestamp = 0; - requestAnimationFrame(animatePanelFade); - } - } + vrPanelVisibility.hide(); } function onWindowResize() { @@ -500,209 +377,74 @@ function render2D() { // 2D Control Panel Functions function init2DControlPanel() { - // Get references to 2D control elements - controlPanel = playerContainer.querySelector('.vrwp-panel'); - videoTitle = playerContainer.querySelector('.vrwp-video-title'); - currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time'); - totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time'); - progressBar = playerContainer.querySelector('.vrwp-bar'); - playedBar = playerContainer.querySelector('.vrwp-played'); - fullscreenBtn = playerContainer.querySelector('.vrwp-fullscreen'); - backBtn = playerContainer.querySelector('.vrwp-back'); - play2Btn = playerContainer.querySelector('.vrwp-play-toggle'); - forwardBtn = playerContainer.querySelector('.vrwp-forward'); - muteBtn = playerContainer.querySelector('.vrwp-mute'); - - if (!controlPanel) { - console.error("2D Control panel not found"); - return; - } - - // Set initial video title - if (videoTitle && video) { - videoTitle.textContent = getVideoTitle(); - } - - // Add event listeners for 2D controls - if (fullscreenBtn) { - fullscreenBtn.addEventListener('click', toggle2DFullscreen); - } - - if (backBtn) { - backBtn.addEventListener('click', () => { - if (video) { - video.currentTime = Math.max(0, video.currentTime - 15); + 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; + } } - show2DControlPanel(); - }); - } - - if (play2Btn) { - play2Btn.addEventListener('click', () => { - togglePlayPause(); - show2DControlPanel(); - }); - } - - if (forwardBtn) { - forwardBtn.addEventListener('click', () => { - if (video && isFinite(video.duration)) { - video.currentTime = Math.min(video.duration, video.currentTime + 15); - } - show2DControlPanel(); - }); - } - - if (muteBtn) { - muteBtn.addEventListener('click', () => { - if (video) { - video.muted = !video.muted; - } - show2DControlPanel(); - }); - } - - if (progressBar) { - progressBar.addEventListener('click', (e) => { - if (video && isFinite(video.duration)) { - const rect = progressBar.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const progress = clickX / rect.width; - video.currentTime = progress * video.duration; - } - show2DControlPanel(); - }); - } + }, + fullscreenTarget: playerContainer, + getIsActive: () => is2DMode, + playerContainer, + title: getVideoTitle() + }); } function show2DControlPanel() { - if (!is2DMode || !controlPanel) return; - - clearTimeout(controlPanelTimeout); - controlPanel.classList.add('visible'); - isControlPanelVisible = true; - - controlPanelTimeout = setTimeout(hide2DControlPanel, CONTROL_PANEL_HIDE_DELAY); + twoDControls?.show(); } function hide2DControlPanel() { - if (!controlPanel) return; - - clearTimeout(controlPanelTimeout); - controlPanel.classList.remove('visible'); - isControlPanelVisible = false; + twoDControls?.hide(); } function update2DControlPanel() { if (!is2DMode || !video) return; - - // Update time displays - if (currentTimeDisplay) { - currentTimeDisplay.textContent = formatTime(video.currentTime); - } - - if (totalTimeDisplay && isFinite(video.duration)) { - totalTimeDisplay.textContent = formatTime(video.duration); - } - - // Update progress bar - if (playedBar && isFinite(video.duration) && video.duration > 0) { - const progress = (video.currentTime / video.duration) * 100; - playedBar.style.width = `${progress}%`; - } + + twoDControls?.updateTime(video.currentTime, video.duration); } function update2DPlayPauseButton() { - if (!is2DMode || !play2Btn || !video) return; - - if (video.paused || video.ended) { - play2Btn.classList.remove('playing'); - play2Btn.classList.add('paused'); - setLucideIcon(play2Btn, 'play'); - } else { - play2Btn.classList.remove('paused'); - play2Btn.classList.add('playing'); - setLucideIcon(play2Btn, 'pause'); - } + if (!is2DMode || !video) return; + + twoDControls?.updatePlaybackButton(video.paused || video.ended); } function update2DMuteButton() { - if (!is2DMode || !muteBtn || !video) return; - - if (video.muted) { - // Video is muted, show unmute icon (user can click to unmute) - muteBtn.classList.remove('muted'); - muteBtn.classList.add('unmuted'); - setLucideIcon(muteBtn, 'volume-x'); - } else { - // Video is unmuted, show mute icon (user can click to mute) - muteBtn.classList.remove('unmuted'); - muteBtn.classList.add('muted'); - setLucideIcon(muteBtn, 'volume-2'); - } -} + if (!is2DMode || !video) return; -function toggle2DFullscreen() { - if (!document.fullscreenElement) { - // Enter fullscreen - const container = playerContainer; - if (container && container.requestFullscreen) { - container.requestFullscreen().catch(err => { - console.error('Error attempting to enable fullscreen:', err); - }); - } - } else { - // Exit fullscreen - if (document.exitFullscreen) { - document.exitFullscreen().catch(err => { - console.error('Error attempting to exit fullscreen:', err); - }); - } - } + twoDControls?.updateMuteButton(video.muted); } function handle2DVideoEnd() { if (!is2DMode || !video) return; - - // Keep video at last frame (don't reset currentTime) - // Video is already paused by onVideoEnded() - - // Show control panel and keep it visible (no auto-hide timeout) - if (controlPanel) { - clearTimeout(controlPanelTimeout); - controlPanel.classList.add('visible'); - isControlPanelVisible = true; - // Don't set timeout - panel stays visible until user interacts - } - - // Update play button to show replay state + + twoDControls?.showPersistent(); update2DPlayPauseButton(); } function position2DControlPanel() { - if (!is2DMode || !controlPanel || !renderer) return; - - // Get the canvas dimensions and position - const canvas = renderer.domElement; - const canvasRect = canvas.getBoundingClientRect(); - const containerRect = playerContainer.getBoundingClientRect(); - - // Calculate 10% from the bottom of the canvas - const bottomOffset = canvasRect.height * 0.1; - - // Get the panel's height - const panelHeight = controlPanel.offsetHeight; - - // Calculate the top position: canvas bottom minus offset minus panel height, relative to container - const topPosition = (canvasRect.bottom - containerRect.top) - bottomOffset - panelHeight; - - // Position the panel so its bottom edge is 10% from canvas bottom - controlPanel.style.position = 'absolute'; - controlPanel.style.top = `${topPosition}px`; - controlPanel.style.bottom = 'auto'; // Clear any previous bottom positioning - controlPanel.style.left = '50%'; - controlPanel.style.transform = 'translateX(-50%)'; - controlPanel.style.zIndex = '1000'; // Ensure it's above the canvas + if (!renderer) return; + + twoDControls?.position(renderer.domElement); } function hidePlayButton() { @@ -812,44 +554,39 @@ function onVideoEnded() { } function onSelectStartVR(event) { - const controller = event.target; - if (!raycaster) return; - controller.updateMatrixWorld(); - tempMatrix.identity().extractRotation(controller.matrixWorld); - raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); - raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); - const allInteractables = [...uiElements]; - const directIntersects = raycaster.intersectObjects(allInteractables, true); - if (directIntersects.length > 0) { - const firstIntersected = directIntersects[0].object; - const intersectionPoint = directIntersects[0].point; - if (firstIntersected.name === "vrPlayPauseButton") { - togglePlayPause(); showPanel(); - } else if (firstIntersected.name === "vrRewindButton") { - if (video) { video.currentTime = Math.max(0, video.currentTime - 15); updateSeekBarAppearance(); } - showPanel(); - } else if (firstIntersected.name === "vrForwardButton") { - if (video && isFinite(video.duration)) { video.currentTime = Math.min(video.duration, video.currentTime + 15); updateSeekBarAppearance(); } - showPanel(); - } else if (firstIntersected.name === "vrExitButton") { - if (xrSession) actualSessionToggle(); // Should trigger exit - showPanel(); // Keep panel briefly visible or hide, depending on desired UX - } else if (firstIntersected.name === "vrVolumeButton") { + handleVrControllerSelect(event, { + exitVr: () => { + if (xrSession) actualSessionToggle(); + }, + forward: () => { + if (video && isFinite(video.duration)) { + video.currentTime = Math.min(video.duration, video.currentTime + 15); + updateSeekBarAppearance(); + } + }, + hidePanel, + isPanelVisible: () => vrPanelVisibility.isVisible, + raycaster, + rewind: () => { + if (video) { + video.currentTime = Math.max(0, video.currentTime - 15); + updateSeekBarAppearance(); + } + }, + seek: (progress) => { + if (video && isFinite(video.duration)) { + video.currentTime = progress * video.duration; + updateSeekBarAppearance(); + } + }, + showPanel, + toggleMute: () => { if (video) video.muted = !video.muted; - showPanel(); - } else if (firstIntersected.name === "seekBarHitArea" && video && isFinite(video.duration)) { - showPanel(); - const newTime = getSeekProgressFromIntersection(vrPanel, intersectionPoint) * video.duration; - video.currentTime = newTime; - updateSeekBarAppearance(); - } else if (firstIntersected.name === "vrControlPanelBackground" || firstIntersected === activeContentMesh) { - if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel(); - } else { - if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel(); - } - } else { - if (vrControlPanel && vrControlPanel.visible && panelOpacity > 0.01) hidePanel(); else showPanel(); - } + }, + togglePlayPause, + uiElements, + vrPanel + }); } async function handleEnterVRButtonClick() { @@ -982,11 +719,7 @@ async function actualSessionToggle() { xrSession = null; if (vrControlPanel) { - clearTimeout(panelHideTimeout); - panelTargetOpacity = 0; - panelOpacity = 0; - hideVrPanelImmediately(vrPanel); - isPanelFading = false; + vrPanelVisibility.hideImmediately(); } sessionToClose.end().catch(err => { console.error("Error calling .end() on session:", err); @@ -1034,16 +767,13 @@ async function actualSessionToggle() { updateVRPlayPauseButtonIcon(); updateVRVolumeButtonIcon(); if (vrControlPanel) { - panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false; - clearTimeout(panelHideTimeout); - hideVrPanelImmediately(vrPanel); + vrPanelVisibility.hideImmediately(); } await renderer.xr.setSession(xrSession); isXrLoopActive = true; renderer.setAnimationLoop(renderXR); frameCounter = 0; - lastFadeTimestamp = performance.now(); } catch (err) { const sessionStartError = "XR_ERROR: Failed to start VR session: " + (err.message || String(err)); @@ -1054,9 +784,7 @@ async function actualSessionToggle() { if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; } if (videoTexture) { videoTexture.dispose(); videoTexture = null; } if (vrControlPanel) { - panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false; - clearTimeout(panelHideTimeout); - hideVrPanelImmediately(vrPanel); + vrPanelVisibility.hideImmediately(); } if (xrSession) { xrSession.removeEventListener('end', onVRSessionEnd); @@ -1104,10 +832,7 @@ function onVRSessionEnd(event) { } hideContentMeshes(); if (vrControlPanel) { - clearTimeout(panelHideTimeout); - isPanelFading = false; - panelOpacity = 0; panelTargetOpacity = 0; - hideVrPanelImmediately(vrPanel); + vrPanelVisibility.hideImmediately(); } if (endedSession && typeof endedSession.removeEventListener === 'function') { @@ -1128,10 +853,6 @@ function onVRSessionEnd(event) { } -function handleControllerInteractions() { - if (!renderer || !renderer.xr || !renderer.xr.isPresenting || !controller1) return; -} - function renderXR(timestamp, frame) { if (!isXrLoopActive) { return; @@ -1147,7 +868,7 @@ function renderXR(timestamp, frame) { return; } - if (isPanelFading) { + if (vrPanelVisibility.isFading) { animatePanelFade(timestamp); } @@ -1168,7 +889,6 @@ function renderXR(timestamp, frame) { if (videoTexture && video && !video.paused && !video.ended) { videoTexture.needsUpdate = true; } - handleControllerInteractions(); renderer.render(scene, camera); } catch (error) { const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error));