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