1
0

Further refactor

This commit is contained in:
Aiden
2026-06-10 11:37:02 +10:00
parent 899027e531
commit cb332abd4f
6 changed files with 660 additions and 397 deletions

View File

@@ -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
};
}

View File

@@ -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<HTMLElement>('.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);
});
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}
}

View File

@@ -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();
bindVideoEvents({
onEnded: onVideoEnded,
onPlaybackStateChange: () => {
updateVRPlayPauseButtonIcon();
update2DPlayPauseButton();
},
onTimelineChange: () => {
updateSeekBarAppearance();
update2DControlPanel();
},
onVolumeChange: () => {
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)) {
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);
},
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);
}
show2DControlPanel();
});
}
if (play2Btn) {
play2Btn.addEventListener('click', () => {
togglePlayPause();
show2DControlPanel();
});
}
if (forwardBtn) {
forwardBtn.addEventListener('click', () => {
twoDControls = new TwoDControlPanel({
callbacks: {
onForward: () => {
if (video && isFinite(video.duration)) {
video.currentTime = Math.min(video.duration, video.currentTime + 15);
}
show2DControlPanel();
});
}
if (muteBtn) {
muteBtn.addEventListener('click', () => {
},
onMute: () => {
if (video) {
video.muted = !video.muted;
}
show2DControlPanel();
});
},
onPlayPause: togglePlayPause,
onRewind: () => {
if (video) {
video.currentTime = Math.max(0, video.currentTime - 15);
}
if (progressBar) {
progressBar.addEventListener('click', (e) => {
},
onSeek: (progress) => {
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 (!is2DMode || !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');
}
twoDControls?.updatePlaybackButton(video.paused || video.ended);
}
function update2DMuteButton() {
if (!is2DMode || !muteBtn || !video) return;
if (!is2DMode || !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');
}
}
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;
if (!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
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") {
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;
handleVrControllerSelect(event, {
exitVr: () => {
if (xrSession) actualSessionToggle();
},
forward: () => {
if (video && isFinite(video.duration)) {
video.currentTime = Math.min(video.duration, video.currentTime + 15);
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();
},
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;
},
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));