1
0

more refactoer

This commit is contained in:
Aiden
2026-06-10 11:51:34 +10:00
parent cb332abd4f
commit f5c82d3b78
4 changed files with 678 additions and 382 deletions

View File

@@ -0,0 +1,133 @@
type MediaControllerOptions = {
is2DModeActive: () => boolean;
on2DPlaybackResume: () => void;
playButton?: HTMLButtonElement;
video: HTMLVideoElement;
};
type HandleMediaEndedOptions = {
cleanupFailedVrExit: () => void;
exitVr: () => Promise<void>;
isIn2DMode: () => boolean;
isInVr: () => boolean;
on2DEnded: () => void;
resetToOriginalState: () => void;
};
const DEFAULT_SKIP_SECONDS = 15;
export class MediaController {
private readonly is2DModeActive: () => boolean;
private readonly on2DPlaybackResume: () => void;
private readonly playButton?: HTMLButtonElement;
private readonly video: HTMLVideoElement;
constructor({ is2DModeActive, on2DPlaybackResume, playButton, video }: MediaControllerOptions) {
this.is2DModeActive = is2DModeActive;
this.on2DPlaybackResume = on2DPlaybackResume;
this.playButton = playButton;
this.video = video;
}
enableNativeControls(): void {
this.video.controls = true;
}
forward(seconds = DEFAULT_SKIP_SECONDS): void {
if (!isFinite(this.video.duration)) return;
this.video.currentTime = Math.min(this.video.duration, this.video.currentTime + seconds);
}
handleEnded({
cleanupFailedVrExit,
exitVr,
isIn2DMode,
isInVr,
on2DEnded,
resetToOriginalState
}: HandleMediaEndedOptions): void {
this.pauseIfPlaying();
if (isInVr()) {
exitVr().catch((err) => {
console.error('Error during automatic VR exit on video end:', err);
cleanupFailedVrExit();
});
return;
}
if (isIn2DMode()) {
on2DEnded();
return;
}
resetToOriginalState();
}
hidePlayButton(): void {
this.playButton?.classList.add('hidden');
}
pauseIfPlaying(): void {
if (!this.video.paused) {
this.video.pause();
}
}
play(): Promise<void> {
return this.video.play();
}
resetToOriginalState(): void {
this.video.pause();
this.video.currentTime = 0;
this.video.controls = false;
this.video.load();
this.playButton?.classList.remove('hidden');
if (this.playButton) {
this.playButton.disabled = false;
}
}
rewind(seconds = DEFAULT_SKIP_SECONDS): void {
this.video.currentTime = Math.max(0, this.video.currentTime - seconds);
}
seekToProgress(progress: number): void {
if (!isFinite(this.video.duration)) return;
this.video.currentTime = progress * this.video.duration;
}
toggleMute(): void {
this.video.muted = !this.video.muted;
}
togglePlayPause(): void {
if (!this.video.currentSrc) return;
if (this.video.paused || this.video.ended) {
if (this.video.ended && this.is2DModeActive()) {
this.video.currentTime = 0;
}
if (this.video.readyState >= this.video.HAVE_ENOUGH_DATA || this.video.currentSrc) {
const playPromise = this.video.play() as Promise<void> | undefined;
if (playPromise !== undefined) {
playPromise.then(() => {
if (this.is2DModeActive() && this.video.ended === false) {
this.on2DPlaybackResume();
}
}).catch((err) => console.error('Error during video.play():', err));
} else {
console.error('video.play() did not return a promise.');
}
}
return;
}
this.video.pause();
}
}

View File

@@ -0,0 +1,150 @@
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
const FALLBACK_ASPECT_RATIO = 16 / 9;
const MIN_FALLBACK_CANVAS_WIDTH = 320;
const MIN_FALLBACK_CANVAS_HEIGHT = 180;
type RendererContextHandlers = {
closeActiveXrSession: () => void;
hasActiveXrSession: () => boolean;
restoreAfterContextRestored: () => void;
};
type PlayerRenderer = {
camera: any;
renderer: any;
scene: any;
};
type ResizePlayerRendererOptions = {
camera: any;
camera2D: any;
is2DMode: boolean;
onFallbackResize: () => void;
playerContainer: HTMLElement;
renderer: any;
};
type ResizeFallbackRendererOptions = {
camera2D: any;
playerContainer: HTMLElement;
renderer: any;
};
export function createPlayerRenderer(
playerContainer: HTMLElement,
contextHandlers: RendererContextHandlers
): PlayerRenderer {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 1.6, 0.1);
scene.add(camera);
const renderer = new THREE.WebGLRenderer({ antialias: true });
if (!renderer || !renderer.isWebGLRenderer) {
throw new Error("Failed to create WebGLRenderer or it's not a valid Three.js renderer type.");
}
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
renderer.outputColorSpace = THREE.SRGBColorSpace;
playerContainer.appendChild(renderer.domElement);
hideRendererCanvas(renderer);
bindWebGlContextEvents(renderer, contextHandlers);
return { camera, renderer, scene };
}
export function hideRendererCanvas(renderer: any): void {
if (renderer?.domElement) {
renderer.domElement.style.display = 'none';
}
}
export function resizeFallbackRenderer({
camera2D,
playerContainer,
renderer
}: ResizeFallbackRendererOptions): void {
const { height, width } = getFallbackCanvasSize(playerContainer);
renderer.setSize(width, height);
camera2D.aspect = width / height;
camera2D.updateProjectionMatrix();
styleFallbackCanvas(renderer.domElement);
}
export function resizePlayerRenderer({
camera,
camera2D,
is2DMode,
onFallbackResize,
playerContainer,
renderer
}: ResizePlayerRendererOptions): void {
if (!renderer) return;
if (renderer.xr && renderer.xr.isPresenting) return;
if (is2DMode) {
if (!playerContainer || !camera2D) return;
resizeFallbackRenderer({ camera2D, playerContainer, renderer });
onFallbackResize();
return;
}
if (camera) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
if (camera2D) {
camera2D.aspect = window.innerWidth / window.innerHeight;
camera2D.updateProjectionMatrix();
}
}
export function showFallbackCanvas(renderer: any): HTMLElement {
const canvas = renderer.domElement;
canvas.style.position = 'relative';
styleFallbackCanvas(canvas);
canvas.style.display = '';
return canvas;
}
function bindWebGlContextEvents(renderer: any, handlers: RendererContextHandlers): void {
const gl = renderer.getContext();
if (!gl) {
throw new Error('Failed to get WebGL context from renderer.');
}
gl.canvas.addEventListener('webglcontextlost', (event: Event) => {
event.preventDefault();
console.error('CONTEXT_EVENT: WebGL Context Lost! xrSession active?', handlers.hasActiveXrSession(), event);
if (handlers.hasActiveXrSession()) {
handlers.closeActiveXrSession();
}
}, false);
gl.canvas.addEventListener('webglcontextrestored', () => {
console.log('CONTEXT_EVENT: WebGL Context Restored.');
handlers.restoreAfterContextRestored();
}, false);
}
function getFallbackCanvasSize(playerContainer: HTMLElement): { height: number; width: number } {
const containerRect = playerContainer.getBoundingClientRect();
const containerWidth = containerRect.width;
const calculatedHeight = containerWidth / FALLBACK_ASPECT_RATIO;
return {
height: Math.max(calculatedHeight, MIN_FALLBACK_CANVAS_HEIGHT),
width: Math.max(containerWidth, MIN_FALLBACK_CANVAS_WIDTH)
};
}
function styleFallbackCanvas(canvas: HTMLElement): void {
canvas.style.width = '100%';
canvas.style.height = 'auto';
canvas.style.aspectRatio = '16/9';
}

View File

@@ -0,0 +1,265 @@
import type { ProjectionMode } from './config.js';
import type { FallbackCameraControls } from './fallback-camera-controls.js';
import {
hideRendererCanvas,
resizeFallbackRenderer,
showFallbackCanvas
} from './renderer-lifecycle.js';
import { TwoDControlPanel } from './two-d-control-panel.js';
type TwoDModeCallbacks = {
createMediaTexture: () => any;
forward: () => void;
positionPlaneForPresentation: (isFallback2D?: boolean) => void;
rewind: () => void;
seekToProgress: (progress: number) => void;
showActiveContentMesh: () => void;
toggleMute: () => void;
togglePlayPause: () => void;
};
type TwoDModeOptions = {
callbacks: TwoDModeCallbacks;
fullscreenTarget: HTMLElement;
getActiveContentMesh: () => any;
getCamera: () => any;
getCameraControls: () => FallbackCameraControls | undefined;
getMaterial: () => any;
getRenderer: () => any;
getScene: () => any;
getVideo: () => HTMLVideoElement | undefined;
playerContainer: HTMLElement;
projectionMode: ProjectionMode;
title: string;
};
const FULLSCREEN_RESIZE_DELAY = 100;
export class TwoDMode {
private readonly callbacks: TwoDModeCallbacks;
private readonly controls: TwoDControlPanel;
private readonly fullscreenTarget: HTMLElement;
private readonly getActiveContentMesh: () => any;
private readonly getCamera: () => any;
private readonly getCameraControls: () => FallbackCameraControls | undefined;
private readonly getMaterial: () => any;
private readonly getRenderer: () => any;
private readonly getScene: () => any;
private readonly getVideo: () => HTMLVideoElement | undefined;
private readonly playerContainer: HTMLElement;
private readonly projectionMode: ProjectionMode;
private active = false;
constructor(options: TwoDModeOptions) {
this.callbacks = options.callbacks;
this.fullscreenTarget = options.fullscreenTarget;
this.getActiveContentMesh = options.getActiveContentMesh;
this.getCamera = options.getCamera;
this.getCameraControls = options.getCameraControls;
this.getMaterial = options.getMaterial;
this.getRenderer = options.getRenderer;
this.getScene = options.getScene;
this.getVideo = options.getVideo;
this.playerContainer = options.playerContainer;
this.projectionMode = options.projectionMode;
this.controls = new TwoDControlPanel({
callbacks: {
onForward: () => {
this.callbacks.forward();
},
onMute: () => {
this.callbacks.toggleMute();
},
onPlayPause: this.callbacks.togglePlayPause,
onRewind: () => {
this.callbacks.rewind();
},
onSeek: (progress) => {
this.callbacks.seekToProgress(progress);
}
},
fullscreenTarget: this.fullscreenTarget,
getIsActive: () => this.active,
playerContainer: this.playerContainer,
title: options.title
});
}
get isActive(): boolean {
return this.active;
}
start(): void {
const video = this.getVideo();
const renderer = this.getRenderer();
const camera = this.getCamera();
if (!video || !renderer || !camera) {
console.error("Required components not available for 2D mode");
return;
}
this.active = true;
this.resizeCanvasFor2D(renderer, camera);
const canvas = showFallbackCanvas(renderer);
video.style.display = 'none';
const mediaTexture = this.callbacks.createMediaTexture();
this.callbacks.positionPlaneForPresentation(this.projectionMode === 'plane');
const material = this.getMaterial();
const activeContentMesh = this.getActiveContentMesh();
if (material && activeContentMesh) {
material.map = mediaTexture;
material.needsUpdate = true;
this.callbacks.showActiveContentMesh();
}
this.callbacks.togglePlayPause();
this.addEventListeners(canvas);
this.controls.show();
this.positionControls();
this.render();
}
stop(): void {
this.active = false;
const renderer = this.getRenderer();
if (renderer?.domElement) {
this.removeEventListeners(renderer.domElement);
hideRendererCanvas(renderer);
} else {
this.removeFullscreenEventListeners();
}
this.controls.hide();
this.getCameraControls()?.reset();
this.callbacks.positionPlaneForPresentation(false);
const video = this.getVideo();
if (video) {
video.style.display = '';
}
}
resize(): boolean {
if (!this.active) {
return false;
}
const renderer = this.getRenderer();
const camera = this.getCamera();
if (renderer && camera) {
this.resizeCanvasFor2D(renderer, camera);
this.positionControls();
}
return true;
}
showControls(): void {
this.controls.show();
}
hideControls(): void {
this.controls.hide();
}
updateTimeline(): void {
if (!this.active) return;
const video = this.getVideo();
if (video) {
this.controls.updateTime(video.currentTime, video.duration);
}
}
updatePlaybackButton(): void {
if (!this.active) return;
const video = this.getVideo();
if (video) {
this.controls.updatePlaybackButton(video.paused || video.ended);
}
}
updateMuteButton(): void {
if (!this.active) return;
const video = this.getVideo();
if (video) {
this.controls.updateMuteButton(video.muted);
}
}
handleVideoEnd(): void {
if (!this.active) return;
this.controls.showPersistent();
this.updatePlaybackButton();
}
private addEventListeners(canvas: HTMLElement): void {
this.getCameraControls()?.addEventListeners(canvas, this.projectionMode);
document.addEventListener('fullscreenchange', this.onFullscreenChange);
document.addEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.addEventListener('mozfullscreenchange', this.onFullscreenChange);
document.addEventListener('MSFullscreenChange', this.onFullscreenChange);
}
private removeEventListeners(canvas: HTMLElement): void {
this.getCameraControls()?.removeEventListeners(canvas);
this.removeFullscreenEventListeners();
}
private removeFullscreenEventListeners(): void {
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
document.removeEventListener('webkitfullscreenchange', this.onFullscreenChange);
document.removeEventListener('mozfullscreenchange', this.onFullscreenChange);
document.removeEventListener('MSFullscreenChange', this.onFullscreenChange);
}
private readonly onFullscreenChange = (): void => {
if (!this.active) return;
window.setTimeout(() => {
this.resize();
}, FULLSCREEN_RESIZE_DELAY);
};
positionControls(): void {
const renderer = this.getRenderer();
if (renderer?.domElement) {
this.controls.position(renderer.domElement);
}
}
private readonly render = (): void => {
if (!this.active) return;
const camera = this.getCamera();
if (this.projectionMode === 'vr180') {
this.getCameraControls()?.updateCameraRotation();
} else if (camera) {
camera.rotation.set(0, 0, 0);
}
const renderer = this.getRenderer();
const scene = this.getScene();
if (renderer && camera && scene) {
renderer.render(scene, camera);
}
requestAnimationFrame(this.render);
};
private resizeCanvasFor2D(renderer: any, camera: any): void {
resizeFallbackRenderer({
camera2D: camera,
playerContainer: this.playerContainer,
renderer
});
}
}

View File

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