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, DEFAULT_PROJECTION,
PLANE_2D_DISTANCE, PLANE_2D_DISTANCE,
PLANE_DISTANCE, PLANE_DISTANCE,
PLANE_HEIGHT,
PLANE_WIDTH,
PLAYER_SELECTOR, PLAYER_SELECTOR,
type ProjectionMode, type ProjectionMode,
VALID_PROJECTIONS VALID_PROJECTIONS
} from './config.js'; } from './config.js';
import { createContentScene } from './content-scene.js';
import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom.js'; import { create2DControlPanel, createPlayButton, injectPlayerStyles } from './dom.js';
import { import {
applySbsTextureWindow as applySbsTextureWindowCore, applySbsTextureWindow as applySbsTextureWindowCore,
@@ -17,19 +16,21 @@ import {
showActiveContentMesh as showActiveContentMeshCore showActiveContentMesh as showActiveContentMeshCore
} from './projection.js'; } from './projection.js';
import { createVideoTexture as createVideoTextureCore } from './three-utils.js'; import { createVideoTexture as createVideoTextureCore } from './three-utils.js';
import { setLucideIcon } from './icons.js';
import { FallbackCameraControls } from './fallback-camera-controls.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 { import {
createVrControlPanel, createVrControlPanel,
getSeekProgressFromIntersection,
hideVrPanelImmediately,
setVrPanelOpacity,
type VrControlPanel, type VrControlPanel,
updateVrPlayPauseButtonIcon, updateVrPlayPauseButtonIcon,
updateVrSeekBarAppearance, updateVrSeekBarAppearance,
updateVrVolumeButtonIcon updateVrVolumeButtonIcon
} from './vr-control-panel.js'; } 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; 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 scene, camera, renderer, video, videoTexture, sphereMaterial;
let vr180Mesh, planeMesh, activeContentMesh; let vr180Mesh, planeMesh, activeContentMesh;
let xrSession = null; let xrSession = null;
let controller1, raycaster, uiElements = []; let raycaster, uiElements = [];
const tempMatrix = new THREE.Matrix4();
let videoElement, playBtn; let videoElement, playBtn;
let frameCounter = 0; 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 isXrLoopActive = false;
let is2DMode = false; let is2DMode = false;
let vrControlPanel; let vrControlPanel;
let vrPanel: VrControlPanel | undefined; let vrPanel: VrControlPanel | undefined;
let twoDControls: TwoDControlPanel | undefined;
const vrPanelVisibility = new VrPanelVisibility();
// 2D Camera Controls // 2D Camera Controls
let camera2D; let camera2D;
let fallbackCameraControls: FallbackCameraControls | undefined; 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); injectPlayerStyles(_playerBase);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -221,48 +207,16 @@ function init() {
}, false); }, false);
video = videoElement; video = videoElement;
const contentScene = createContentScene(scene, projectionMode, (renderer, scene, activeCamera, geometry, material, group) => {
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) {
applySbsTextureWindow(renderer, activeCamera, material); applySbsTextureWindow(renderer, activeCamera, material);
}; });
sphereMaterial = contentScene.material;
const planeGeometry = new THREE.PlaneGeometry(PLANE_WIDTH, PLANE_HEIGHT); vr180Mesh = contentScene.vr180Mesh;
planeMesh = new THREE.Mesh(planeGeometry, sphereMaterial); planeMesh = contentScene.planeMesh;
planeMesh.name = "vrSbsPlaneMesh"; activeContentMesh = contentScene.activeContentMesh;
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;
uiElements.push(activeContentMesh); uiElements.push(activeContentMesh);
// Initialize 2D camera camera2D = contentScene.fallbackCamera;
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);
fallbackCameraControls = new FallbackCameraControls(camera2D, { fallbackCameraControls = new FallbackCameraControls(camera2D, {
hideControls: hide2DControlPanel, hideControls: hide2DControlPanel,
isEnabled: () => is2DMode, isEnabled: () => is2DMode,
@@ -277,19 +231,10 @@ function init() {
try { // Phase 2: VR Control Panel UI try { // Phase 2: VR Control Panel UI
vrPanel = createVrControlPanel(scene, getVideoTitle()); vrPanel = createVrControlPanel(scene, getVideoTitle());
vrControlPanel = vrPanel.group; vrControlPanel = vrPanel.group;
vrPanelVisibility.setPanel(vrPanel);
uiElements.push(...vrPanel.interactables); uiElements.push(...vrPanel.interactables);
panelOpacity = 0; raycaster = createVrController(scene, renderer, onSelectStartVR).raycaster;
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;
} catch (e) { } catch (e) {
console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e); console.error("INIT_ERROR (Phase 2 - VR Controls Setup):", e);
} }
@@ -301,46 +246,23 @@ function init() {
window.addEventListener('resize', onWindowResize); window.addEventListener('resize', onWindowResize);
if (video) { if (video) {
video.onloadedmetadata = () => { bindVideoEvents({
if (isFinite(video.duration) && playBtn) { onEnded: onVideoEnded,
// Enable button for both VR and non-VR scenarios when video is ready onPlaybackStateChange: () => {
playBtn.disabled = false; updateVRPlayPauseButtonIcon();
} update2DPlayPauseButton();
updateSeekBarAppearance(); },
updateVRPlayPauseButtonIcon(); onTimelineChange: () => {
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(); updateSeekBarAppearance();
update2DControlPanel(); update2DControlPanel();
} },
}; onVolumeChange: () => {
video.onplaying = () => { updateVRVolumeButtonIcon();
updateVRPlayPauseButtonIcon(); update2DMuteButton();
update2DPlayPauseButton(); },
}; playButton: playBtn,
video.onpause = () => { video
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);
} }
// Initialize 2D control panel // Initialize 2D control panel
@@ -373,60 +295,15 @@ function updateSeekBarAppearance() {
} }
function animatePanelFade(timestamp) { function animatePanelFade(timestamp) {
if (!vrControlPanel) return; vrPanelVisibility.updateFade(timestamp);
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);
} }
function showPanel() { function showPanel() {
if (vrControlPanel) vrControlPanel.visible = true; vrPanelVisibility.show();
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);
} }
function hidePanel() { function hidePanel() {
clearTimeout(panelHideTimeout); vrPanelVisibility.hide();
if (panelTargetOpacity !== 0.0 || panelOpacity > 0.0) {
panelTargetOpacity = 0.0;
if (!isPanelFading) {
isPanelFading = true;
lastFadeTimestamp = 0;
requestAnimationFrame(animatePanelFade);
}
}
} }
function onWindowResize() { function onWindowResize() {
@@ -500,209 +377,74 @@ function render2D() {
// 2D Control Panel Functions // 2D Control Panel Functions
function init2DControlPanel() { function init2DControlPanel() {
// Get references to 2D control elements twoDControls = new TwoDControlPanel({
controlPanel = playerContainer.querySelector('.vrwp-panel'); callbacks: {
videoTitle = playerContainer.querySelector('.vrwp-video-title'); onForward: () => {
currentTimeDisplay = playerContainer.querySelector('.vrwp-current-time'); if (video && isFinite(video.duration)) {
totalTimeDisplay = playerContainer.querySelector('.vrwp-total-time'); video.currentTime = Math.min(video.duration, video.currentTime + 15);
progressBar = playerContainer.querySelector('.vrwp-bar'); }
playedBar = playerContainer.querySelector('.vrwp-played'); },
fullscreenBtn = playerContainer.querySelector('.vrwp-fullscreen'); onMute: () => {
backBtn = playerContainer.querySelector('.vrwp-back'); if (video) {
play2Btn = playerContainer.querySelector('.vrwp-play-toggle'); video.muted = !video.muted;
forwardBtn = playerContainer.querySelector('.vrwp-forward'); }
muteBtn = playerContainer.querySelector('.vrwp-mute'); },
onPlayPause: togglePlayPause,
if (!controlPanel) { onRewind: () => {
console.error("2D Control panel not found"); if (video) {
return; video.currentTime = Math.max(0, video.currentTime - 15);
} }
},
// Set initial video title onSeek: (progress) => {
if (videoTitle && video) { if (video && isFinite(video.duration)) {
videoTitle.textContent = getVideoTitle(); video.currentTime = progress * video.duration;
} }
// 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(); },
}); fullscreenTarget: playerContainer,
} getIsActive: () => is2DMode,
playerContainer,
if (play2Btn) { title: getVideoTitle()
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();
});
}
} }
function show2DControlPanel() { function show2DControlPanel() {
if (!is2DMode || !controlPanel) return; twoDControls?.show();
clearTimeout(controlPanelTimeout);
controlPanel.classList.add('visible');
isControlPanelVisible = true;
controlPanelTimeout = setTimeout(hide2DControlPanel, CONTROL_PANEL_HIDE_DELAY);
} }
function hide2DControlPanel() { function hide2DControlPanel() {
if (!controlPanel) return; twoDControls?.hide();
clearTimeout(controlPanelTimeout);
controlPanel.classList.remove('visible');
isControlPanelVisible = false;
} }
function update2DControlPanel() { function update2DControlPanel() {
if (!is2DMode || !video) return; if (!is2DMode || !video) return;
// Update time displays twoDControls?.updateTime(video.currentTime, video.duration);
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}%`;
}
} }
function update2DPlayPauseButton() { function update2DPlayPauseButton() {
if (!is2DMode || !play2Btn || !video) return; if (!is2DMode || !video) return;
if (video.paused || video.ended) { twoDControls?.updatePlaybackButton(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');
}
} }
function update2DMuteButton() { function update2DMuteButton() {
if (!is2DMode || !muteBtn || !video) return; if (!is2DMode || !video) return;
if (video.muted) { twoDControls?.updateMuteButton(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);
});
}
}
} }
function handle2DVideoEnd() { function handle2DVideoEnd() {
if (!is2DMode || !video) return; if (!is2DMode || !video) return;
// Keep video at last frame (don't reset currentTime) twoDControls?.showPersistent();
// 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
update2DPlayPauseButton(); update2DPlayPauseButton();
} }
function position2DControlPanel() { function position2DControlPanel() {
if (!is2DMode || !controlPanel || !renderer) return; if (!renderer) return;
// Get the canvas dimensions and position twoDControls?.position(renderer.domElement);
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
} }
function hidePlayButton() { function hidePlayButton() {
@@ -812,44 +554,39 @@ function onVideoEnded() {
} }
function onSelectStartVR(event) { function onSelectStartVR(event) {
const controller = event.target; handleVrControllerSelect(event, {
if (!raycaster) return; exitVr: () => {
controller.updateMatrixWorld(); if (xrSession) actualSessionToggle();
tempMatrix.identity().extractRotation(controller.matrixWorld); },
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld); forward: () => {
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix); if (video && isFinite(video.duration)) {
const allInteractables = [...uiElements]; video.currentTime = Math.min(video.duration, video.currentTime + 15);
const directIntersects = raycaster.intersectObjects(allInteractables, true); updateSeekBarAppearance();
if (directIntersects.length > 0) { }
const firstIntersected = directIntersects[0].object; },
const intersectionPoint = directIntersects[0].point; hidePanel,
if (firstIntersected.name === "vrPlayPauseButton") { isPanelVisible: () => vrPanelVisibility.isVisible,
togglePlayPause(); showPanel(); raycaster,
} else if (firstIntersected.name === "vrRewindButton") { rewind: () => {
if (video) { video.currentTime = Math.max(0, video.currentTime - 15); updateSeekBarAppearance(); } if (video) {
showPanel(); video.currentTime = Math.max(0, video.currentTime - 15);
} else if (firstIntersected.name === "vrForwardButton") { updateSeekBarAppearance();
if (video && isFinite(video.duration)) { video.currentTime = Math.min(video.duration, video.currentTime + 15); updateSeekBarAppearance(); } }
showPanel(); },
} else if (firstIntersected.name === "vrExitButton") { seek: (progress) => {
if (xrSession) actualSessionToggle(); // Should trigger exit if (video && isFinite(video.duration)) {
showPanel(); // Keep panel briefly visible or hide, depending on desired UX video.currentTime = progress * video.duration;
} else if (firstIntersected.name === "vrVolumeButton") { updateSeekBarAppearance();
}
},
showPanel,
toggleMute: () => {
if (video) video.muted = !video.muted; if (video) video.muted = !video.muted;
showPanel(); },
} else if (firstIntersected.name === "seekBarHitArea" && video && isFinite(video.duration)) { togglePlayPause,
showPanel(); uiElements,
const newTime = getSeekProgressFromIntersection(vrPanel, intersectionPoint) * video.duration; vrPanel
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();
}
} }
async function handleEnterVRButtonClick() { async function handleEnterVRButtonClick() {
@@ -982,11 +719,7 @@ async function actualSessionToggle() {
xrSession = null; xrSession = null;
if (vrControlPanel) { if (vrControlPanel) {
clearTimeout(panelHideTimeout); vrPanelVisibility.hideImmediately();
panelTargetOpacity = 0;
panelOpacity = 0;
hideVrPanelImmediately(vrPanel);
isPanelFading = false;
} }
sessionToClose.end().catch(err => { sessionToClose.end().catch(err => {
console.error("Error calling .end() on session:", err); console.error("Error calling .end() on session:", err);
@@ -1034,16 +767,13 @@ async function actualSessionToggle() {
updateVRPlayPauseButtonIcon(); updateVRPlayPauseButtonIcon();
updateVRVolumeButtonIcon(); updateVRVolumeButtonIcon();
if (vrControlPanel) { if (vrControlPanel) {
panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false; vrPanelVisibility.hideImmediately();
clearTimeout(panelHideTimeout);
hideVrPanelImmediately(vrPanel);
} }
await renderer.xr.setSession(xrSession); await renderer.xr.setSession(xrSession);
isXrLoopActive = true; isXrLoopActive = true;
renderer.setAnimationLoop(renderXR); renderer.setAnimationLoop(renderXR);
frameCounter = 0; frameCounter = 0;
lastFadeTimestamp = performance.now();
} catch (err) { } catch (err) {
const sessionStartError = "XR_ERROR: Failed to start VR session: " + (err.message || String(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 (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
if (videoTexture) { videoTexture.dispose(); videoTexture = null; } if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
if (vrControlPanel) { if (vrControlPanel) {
panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false; vrPanelVisibility.hideImmediately();
clearTimeout(panelHideTimeout);
hideVrPanelImmediately(vrPanel);
} }
if (xrSession) { if (xrSession) {
xrSession.removeEventListener('end', onVRSessionEnd); xrSession.removeEventListener('end', onVRSessionEnd);
@@ -1104,10 +832,7 @@ function onVRSessionEnd(event) {
} }
hideContentMeshes(); hideContentMeshes();
if (vrControlPanel) { if (vrControlPanel) {
clearTimeout(panelHideTimeout); vrPanelVisibility.hideImmediately();
isPanelFading = false;
panelOpacity = 0; panelTargetOpacity = 0;
hideVrPanelImmediately(vrPanel);
} }
if (endedSession && typeof endedSession.removeEventListener === 'function') { 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) { function renderXR(timestamp, frame) {
if (!isXrLoopActive) { if (!isXrLoopActive) {
return; return;
@@ -1147,7 +868,7 @@ function renderXR(timestamp, frame) {
return; return;
} }
if (isPanelFading) { if (vrPanelVisibility.isFading) {
animatePanelFade(timestamp); animatePanelFade(timestamp);
} }
@@ -1168,7 +889,6 @@ function renderXR(timestamp, frame) {
if (videoTexture && video && !video.paused && !video.ended) { if (videoTexture && video && !video.paused && !video.ended) {
videoTexture.needsUpdate = true; videoTexture.needsUpdate = true;
} }
handleControllerInteractions();
renderer.render(scene, camera); renderer.render(scene, camera);
} catch (error) { } catch (error) {
const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error)); const renderErrorMsg = "ERROR_IN_RENDERXR_LOOP (F" + frameCounter + "): " + (error.message || String(error));