forked from EXT/VR180-Web-Player
Seperation
This commit is contained in:
168
src/vr180player/fallback-camera-controls.ts
Normal file
168
src/vr180player/fallback-camera-controls.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ProjectionMode } from './config.js';
|
||||
|
||||
type CameraControlsCallbacks = {
|
||||
hideControls: () => void;
|
||||
isEnabled: () => boolean;
|
||||
showControls: () => void;
|
||||
};
|
||||
|
||||
const MOUSE_SENSITIVITY = 0.002;
|
||||
const TOUCH_SENSITIVITY = 0.003;
|
||||
const MOMENTUM_DAMPING = 0.8;
|
||||
const MAX_PITCH = Math.PI * (45 / 180);
|
||||
const MAX_YAW = Math.PI * (45 / 180);
|
||||
|
||||
export class FallbackCameraControls {
|
||||
private camera: any;
|
||||
private readonly callbacks: CameraControlsCallbacks;
|
||||
private cameraRotation = { yaw: 0, pitch: 0 };
|
||||
private cameraVelocity = { yaw: 0, pitch: 0 };
|
||||
private dragging = false;
|
||||
private lastMouseX = 0;
|
||||
private lastMouseY = 0;
|
||||
private lastTouchX = 0;
|
||||
private lastTouchY = 0;
|
||||
|
||||
constructor(camera: any, callbacks: CameraControlsCallbacks) {
|
||||
this.camera = camera;
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this.dragging;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cameraRotation = { yaw: 0, pitch: 0 };
|
||||
this.cameraVelocity = { yaw: 0, pitch: 0 };
|
||||
this.dragging = false;
|
||||
if (this.camera) {
|
||||
this.camera.rotation.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
updateCameraRotation(): void {
|
||||
if (!this.camera) return;
|
||||
|
||||
this.cameraRotation.yaw += this.cameraVelocity.yaw;
|
||||
this.cameraRotation.pitch += this.cameraVelocity.pitch;
|
||||
|
||||
this.cameraRotation.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, this.cameraRotation.pitch));
|
||||
this.cameraRotation.yaw = Math.max(-MAX_YAW, Math.min(MAX_YAW, this.cameraRotation.yaw));
|
||||
|
||||
this.cameraVelocity.yaw *= MOMENTUM_DAMPING;
|
||||
this.cameraVelocity.pitch *= MOMENTUM_DAMPING;
|
||||
|
||||
if (Math.abs(this.cameraVelocity.yaw) < 0.001) this.cameraVelocity.yaw = 0;
|
||||
if (Math.abs(this.cameraVelocity.pitch) < 0.001) this.cameraVelocity.pitch = 0;
|
||||
|
||||
this.camera.rotation.set(this.cameraRotation.pitch, this.cameraRotation.yaw, 0);
|
||||
}
|
||||
|
||||
addEventListeners(canvas: HTMLElement, projectionMode: ProjectionMode): void {
|
||||
canvas.addEventListener('mousemove', this.onCanvasMouseMove);
|
||||
|
||||
if (projectionMode === 'vr180') {
|
||||
canvas.addEventListener('mousedown', this.onMouseDown);
|
||||
canvas.addEventListener('mousemove', this.onMouseMove);
|
||||
canvas.addEventListener('mouseup', this.onMouseUp);
|
||||
canvas.addEventListener('touchstart', this.onTouchStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', this.onTouchMove, { passive: false });
|
||||
canvas.addEventListener('touchend', this.onTouchEnd, { passive: false });
|
||||
} else {
|
||||
canvas.addEventListener('touchstart', this.onCanvasTouchStart, { passive: true });
|
||||
}
|
||||
}
|
||||
|
||||
removeEventListeners(canvas: HTMLElement): void {
|
||||
canvas.removeEventListener('mousemove', this.onCanvasMouseMove);
|
||||
canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||
canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||
canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||
canvas.removeEventListener('touchstart', this.onTouchStart);
|
||||
canvas.removeEventListener('touchmove', this.onTouchMove);
|
||||
canvas.removeEventListener('touchend', this.onTouchEnd);
|
||||
canvas.removeEventListener('touchstart', this.onCanvasTouchStart);
|
||||
}
|
||||
|
||||
private readonly onCanvasMouseMove = (): void => {
|
||||
if (this.callbacks.isEnabled() && !this.dragging) {
|
||||
this.callbacks.showControls();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onCanvasTouchStart = (): void => {
|
||||
if (this.callbacks.isEnabled()) {
|
||||
this.callbacks.showControls();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onMouseDown = (event: MouseEvent): void => {
|
||||
if (!this.callbacks.isEnabled()) return;
|
||||
|
||||
this.dragging = true;
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
this.cameraVelocity.yaw = 0;
|
||||
this.cameraVelocity.pitch = 0;
|
||||
this.callbacks.hideControls();
|
||||
};
|
||||
|
||||
private readonly onMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.callbacks.isEnabled() || !this.dragging) return;
|
||||
|
||||
const deltaX = event.clientX - this.lastMouseX;
|
||||
const deltaY = event.clientY - this.lastMouseY;
|
||||
|
||||
this.cameraVelocity.yaw = deltaX * MOUSE_SENSITIVITY;
|
||||
this.cameraVelocity.pitch = deltaY * MOUSE_SENSITIVITY;
|
||||
|
||||
this.lastMouseX = event.clientX;
|
||||
this.lastMouseY = event.clientY;
|
||||
};
|
||||
|
||||
private readonly onMouseUp = (): void => {
|
||||
if (!this.callbacks.isEnabled()) return;
|
||||
|
||||
this.dragging = false;
|
||||
this.callbacks.showControls();
|
||||
};
|
||||
|
||||
private readonly onTouchStart = (event: TouchEvent): void => {
|
||||
if (!this.callbacks.isEnabled()) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
this.dragging = true;
|
||||
this.lastTouchX = event.touches[0].clientX;
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
this.cameraVelocity.yaw = 0;
|
||||
this.cameraVelocity.pitch = 0;
|
||||
this.callbacks.hideControls();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onTouchMove = (event: TouchEvent): void => {
|
||||
if (!this.callbacks.isEnabled() || !this.dragging) return;
|
||||
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const deltaX = event.touches[0].clientX - this.lastTouchX;
|
||||
const deltaY = event.touches[0].clientY - this.lastTouchY;
|
||||
|
||||
this.cameraVelocity.yaw = deltaX * TOUCH_SENSITIVITY;
|
||||
this.cameraVelocity.pitch = deltaY * TOUCH_SENSITIVITY;
|
||||
|
||||
this.lastTouchX = event.touches[0].clientX;
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onTouchEnd = (event: TouchEvent): void => {
|
||||
if (!this.callbacks.isEnabled()) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.dragging = false;
|
||||
this.callbacks.showControls();
|
||||
};
|
||||
}
|
||||
13
src/vr180player/time.ts
Normal file
13
src/vr180player/time.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds)) return '00:00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
322
src/vr180player/vr-control-panel.ts
Normal file
322
src/vr180player/vr-control-panel.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import * as THREE from 'https://unpkg.com/three/build/three.module.js';
|
||||
import { drawLucideIcon } from './icons.js';
|
||||
import { createLucideButtonTexture, drawRoundedRect } from './three-utils.js';
|
||||
|
||||
type ButtonLayout = {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
name: string;
|
||||
size: number;
|
||||
texture: any;
|
||||
};
|
||||
|
||||
export type VrControlPanel = {
|
||||
exitButtonMesh: any;
|
||||
forwardButtonMesh: any;
|
||||
group: any;
|
||||
interactables: any[];
|
||||
playPauseButtonCanvas: HTMLCanvasElement;
|
||||
playPauseButtonContext: CanvasRenderingContext2D | null;
|
||||
playPauseButtonMesh: any;
|
||||
playPauseButtonTexture: any;
|
||||
rewindButtonMesh: any;
|
||||
seekBarHitAreaMesh: any;
|
||||
seekBarProgressMesh: any;
|
||||
seekBarTrackMesh: any;
|
||||
volumeButtonCanvas: HTMLCanvasElement;
|
||||
volumeButtonContext: CanvasRenderingContext2D | null;
|
||||
volumeButtonMesh: any;
|
||||
volumeButtonTexture: any;
|
||||
};
|
||||
|
||||
const FIGMA_PANEL_WIDTH_PX = 450;
|
||||
const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||
const FIGMA_CORNER_RADIUS_PX = 30;
|
||||
const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||
const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||
const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||
const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||
const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||
|
||||
const FIGMA_PLAYPAUSE_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_X_PX = 225;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_REWIND_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_REWIND_BUTTON_X_PX = 169;
|
||||
const FIGMA_REWIND_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_FORWARD_BUTTON_X_PX = 281;
|
||||
const FIGMA_FORWARD_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_EXIT_BUTTON_X_PX = 42;
|
||||
const FIGMA_EXIT_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_VOLUME_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_VOLUME_BUTTON_X_PX = 408;
|
||||
const FIGMA_VOLUME_BUTTON_Y_PX = 90;
|
||||
|
||||
const WORLD_PANEL_WIDTH = 1.5;
|
||||
const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||
const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_TRACK_HEIGHT = FIGMA_SEEK_BAR_HEIGHT_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 5;
|
||||
|
||||
const PANEL_TEXTURE_WIDTH = 1024;
|
||||
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||
const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||
const VR_BUTTON_ICON_SIZE = 82;
|
||||
|
||||
export function createVrControlPanel(scene: any, title: string): VrControlPanel {
|
||||
const group = new THREE.Group();
|
||||
group.position.set(0, 0.5, -1.8);
|
||||
group.rotation.x = 0;
|
||||
scene.add(group);
|
||||
|
||||
const interactables: any[] = [];
|
||||
const panelMesh = createPanelBackground(title);
|
||||
group.add(panelMesh);
|
||||
interactables.push(panelMesh);
|
||||
|
||||
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
const seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh.name = 'seekBarTrackVisual';
|
||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarTrackMesh.position.z = 0.01;
|
||||
seekBarTrackMesh.renderOrder = 1;
|
||||
group.add(seekBarTrackMesh);
|
||||
|
||||
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||
const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||
const seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
|
||||
seekBarProgressMesh.name = 'seekBarProgressVisual';
|
||||
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + SCALE_FACTOR;
|
||||
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
seekBarProgressMesh.position.z = 0.015;
|
||||
seekBarProgressMesh.scale.x = 0.001;
|
||||
seekBarProgressMesh.renderOrder = 2;
|
||||
group.add(seekBarProgressMesh);
|
||||
|
||||
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(
|
||||
WORLD_SEEK_BAR_WIDTH,
|
||||
WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER
|
||||
);
|
||||
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
const seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
|
||||
seekBarHitAreaMesh.name = 'seekBarHitArea';
|
||||
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarHitAreaMesh.position.z = 0.012;
|
||||
seekBarHitAreaMesh.renderOrder = 2;
|
||||
group.add(seekBarHitAreaMesh);
|
||||
interactables.push(seekBarHitAreaMesh);
|
||||
|
||||
const playPauseButtonCanvas = document.createElement('canvas');
|
||||
playPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
playPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
const playPauseButtonContext = playPauseButtonCanvas.getContext('2d');
|
||||
const playPauseButtonTexture = new THREE.CanvasTexture(playPauseButtonCanvas);
|
||||
playPauseButtonTexture.minFilter = THREE.LinearFilter;
|
||||
const playPauseButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_PLAYPAUSE_BUTTON_X_PX,
|
||||
centerY: FIGMA_PLAYPAUSE_BUTTON_Y_PX,
|
||||
name: 'vrPlayPauseButton',
|
||||
size: FIGMA_PLAYPAUSE_BUTTON_SIZE_PX,
|
||||
texture: playPauseButtonTexture
|
||||
});
|
||||
group.add(playPauseButtonMesh);
|
||||
interactables.push(playPauseButtonMesh);
|
||||
|
||||
const rewindButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_REWIND_BUTTON_X_PX,
|
||||
centerY: FIGMA_REWIND_BUTTON_Y_PX,
|
||||
name: 'vrRewindButton',
|
||||
size: FIGMA_REWIND_BUTTON_SIZE_PX,
|
||||
texture: createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
});
|
||||
group.add(rewindButtonMesh);
|
||||
interactables.push(rewindButtonMesh);
|
||||
|
||||
const forwardButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_FORWARD_BUTTON_X_PX,
|
||||
centerY: FIGMA_FORWARD_BUTTON_Y_PX,
|
||||
name: 'vrForwardButton',
|
||||
size: FIGMA_FORWARD_BUTTON_SIZE_PX,
|
||||
texture: createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE, '15')
|
||||
});
|
||||
group.add(forwardButtonMesh);
|
||||
interactables.push(forwardButtonMesh);
|
||||
|
||||
const exitButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_EXIT_BUTTON_X_PX,
|
||||
centerY: FIGMA_EXIT_BUTTON_Y_PX,
|
||||
name: 'vrExitButton',
|
||||
size: FIGMA_EXIT_BUTTON_SIZE_PX,
|
||||
texture: createLucideButtonTexture('log-out', '#ffffff', VR_BUTTON_TEXTURE_SIZE, VR_BUTTON_ICON_SIZE)
|
||||
});
|
||||
group.add(exitButtonMesh);
|
||||
interactables.push(exitButtonMesh);
|
||||
|
||||
const volumeButtonCanvas = document.createElement('canvas');
|
||||
volumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
volumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
const volumeButtonContext = volumeButtonCanvas.getContext('2d');
|
||||
const volumeButtonTexture = new THREE.CanvasTexture(volumeButtonCanvas);
|
||||
volumeButtonTexture.minFilter = THREE.LinearFilter;
|
||||
const volumeButtonMesh = createButtonMesh({
|
||||
centerX: FIGMA_VOLUME_BUTTON_X_PX,
|
||||
centerY: FIGMA_VOLUME_BUTTON_Y_PX,
|
||||
name: 'vrVolumeButton',
|
||||
size: FIGMA_VOLUME_BUTTON_SIZE_PX,
|
||||
texture: volumeButtonTexture
|
||||
});
|
||||
group.add(volumeButtonMesh);
|
||||
interactables.push(volumeButtonMesh);
|
||||
|
||||
group.visible = false;
|
||||
|
||||
return {
|
||||
exitButtonMesh,
|
||||
forwardButtonMesh,
|
||||
group,
|
||||
interactables,
|
||||
playPauseButtonCanvas,
|
||||
playPauseButtonContext,
|
||||
playPauseButtonMesh,
|
||||
playPauseButtonTexture,
|
||||
rewindButtonMesh,
|
||||
seekBarHitAreaMesh,
|
||||
seekBarProgressMesh,
|
||||
seekBarTrackMesh,
|
||||
volumeButtonCanvas,
|
||||
volumeButtonContext,
|
||||
volumeButtonMesh,
|
||||
volumeButtonTexture
|
||||
};
|
||||
}
|
||||
|
||||
export function updateVrPlayPauseButtonIcon(panel: VrControlPanel | undefined, isPausedOrEnded: boolean): void {
|
||||
if (!panel?.playPauseButtonContext || !panel.playPauseButtonTexture) return;
|
||||
|
||||
const ctx = panel.playPauseButtonContext;
|
||||
const canvas = panel.playPauseButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, isPausedOrEnded ? 'play' : 'pause', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
panel.playPauseButtonTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateVrVolumeButtonIcon(panel: VrControlPanel | undefined, isMuted: boolean): void {
|
||||
if (!panel?.volumeButtonContext || !panel.volumeButtonTexture) return;
|
||||
|
||||
const ctx = panel.volumeButtonContext;
|
||||
const canvas = panel.volumeButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconOffset = (canvas.width - VR_BUTTON_ICON_SIZE) / 2;
|
||||
drawLucideIcon(ctx, isMuted ? 'volume-x' : 'volume-2', iconOffset, iconOffset, VR_BUTTON_ICON_SIZE, '#ffffff', 2);
|
||||
panel.volumeButtonTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function updateVrSeekBarAppearance(panel: VrControlPanel | undefined, progress: number | null): void {
|
||||
if (!panel?.seekBarProgressMesh) return;
|
||||
|
||||
if (progress === null) {
|
||||
panel.seekBarProgressMesh.scale.x = 0.0001;
|
||||
panel.seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedProgress = Math.max(0.0001, Math.min(1, progress));
|
||||
panel.seekBarProgressMesh.scale.x = normalizedProgress;
|
||||
panel.seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2 + (WORLD_SEEK_BAR_WIDTH * normalizedProgress) / 2;
|
||||
}
|
||||
|
||||
export function setVrPanelOpacity(panel: VrControlPanel | undefined, opacity: number): void {
|
||||
if (!panel) return;
|
||||
|
||||
panel.group.children.forEach((child: any) => {
|
||||
if (child.material && Object.prototype.hasOwnProperty.call(child.material, 'opacity')) {
|
||||
child.material.opacity = opacity;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function hideVrPanelImmediately(panel: VrControlPanel | undefined): void {
|
||||
if (!panel) return;
|
||||
|
||||
setVrPanelOpacity(panel, 0);
|
||||
panel.group.visible = false;
|
||||
}
|
||||
|
||||
export function getSeekProgressFromIntersection(panel: VrControlPanel | undefined, intersectionPoint: any): number {
|
||||
if (!panel?.seekBarTrackMesh) return 0;
|
||||
|
||||
const localPoint = panel.seekBarTrackMesh.worldToLocal(intersectionPoint.clone());
|
||||
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
|
||||
return Math.max(0, Math.min(1, normalizedPosition));
|
||||
}
|
||||
|
||||
function createPanelBackground(title: string): any {
|
||||
const panelCanvas = document.createElement('canvas');
|
||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||
const panelCtx = panelCanvas.getContext('2d');
|
||||
|
||||
if (!panelCtx) {
|
||||
throw new Error('Unable to create 2D canvas context for VR control panel.');
|
||||
}
|
||||
|
||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||
|
||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||
panelCtx.fillStyle = '#ffffff';
|
||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||
panelCtx.textAlign = 'center';
|
||||
panelCtx.textBaseline = 'top';
|
||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||
panelCtx.fillText(title, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||
|
||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||
panelTexture.minFilter = THREE.LinearFilter;
|
||||
panelTexture.needsUpdate = true;
|
||||
|
||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||
map: panelTexture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||
panelMesh.name = 'vrControlPanelBackground';
|
||||
panelMesh.renderOrder = 0;
|
||||
|
||||
return panelMesh;
|
||||
}
|
||||
|
||||
function createButtonMesh({ centerX, centerY, name, size, texture }: ButtonLayout): any {
|
||||
const buttonWorldSize = size * SCALE_FACTOR;
|
||||
const buttonMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const buttonGeometry = new THREE.PlaneGeometry(buttonWorldSize, buttonWorldSize);
|
||||
const buttonMesh = new THREE.Mesh(buttonGeometry, buttonMaterial);
|
||||
buttonMesh.name = name;
|
||||
buttonMesh.renderOrder = 3;
|
||||
|
||||
const worldButtonOffsetX = (centerX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldButtonOffsetY = -(centerY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
buttonMesh.position.set(worldButtonOffsetX, worldButtonOffsetY, 0.02);
|
||||
|
||||
return buttonMesh;
|
||||
}
|
||||
@@ -16,12 +16,20 @@ import {
|
||||
positionPlaneForPresentation as positionPlaneForPresentationCore,
|
||||
showActiveContentMesh as showActiveContentMeshCore
|
||||
} from './projection.js';
|
||||
import { createVideoTexture as createVideoTextureCore } from './three-utils.js';
|
||||
import { setLucideIcon } from './icons.js';
|
||||
import { FallbackCameraControls } from './fallback-camera-controls.js';
|
||||
import { formatTime } from './time.js';
|
||||
import {
|
||||
createLucideButtonTexture,
|
||||
createVideoTexture as createVideoTextureCore,
|
||||
drawRoundedRect
|
||||
} from './three-utils.js';
|
||||
import { drawLucideIcon, setLucideIcon } from './icons.js';
|
||||
createVrControlPanel,
|
||||
getSeekProgressFromIntersection,
|
||||
hideVrPanelImmediately,
|
||||
setVrPanelOpacity,
|
||||
type VrControlPanel,
|
||||
updateVrPlayPauseButtonIcon,
|
||||
updateVrSeekBarAppearance,
|
||||
updateVrVolumeButtonIcon
|
||||
} from './vr-control-panel.js';
|
||||
|
||||
const _playerBase = new URL('.', import.meta.url).href;
|
||||
|
||||
@@ -44,68 +52,11 @@ const CONTROL_PANEL_HIDE_DELAY = 3000; // 3 seconds
|
||||
let isXrLoopActive = false;
|
||||
let is2DMode = false;
|
||||
let vrControlPanel;
|
||||
let vrPanel: VrControlPanel | undefined;
|
||||
|
||||
// 2D Camera Controls
|
||||
let camera2D;
|
||||
let cameraRotation = { yaw: 0, pitch: 0 };
|
||||
let cameraVelocity = { yaw: 0, pitch: 0 };
|
||||
let isDragging = false;
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
let lastTouchX = 0;
|
||||
let lastTouchY = 0;
|
||||
|
||||
// 2D Control Constants
|
||||
const MOUSE_SENSITIVITY = 0.002;
|
||||
const TOUCH_SENSITIVITY = 0.003;
|
||||
const MOMENTUM_DAMPING = 0.8; // Reduced from 0.9 for 50% less inertia
|
||||
const MAX_PITCH = Math.PI * (45 / 180); // ~45 degrees - edge of VR180 content aligns with viewport edge
|
||||
const MAX_YAW = Math.PI * (45 / 180); // ~45 degrees - edge of VR180 content aligns with viewport edge
|
||||
|
||||
// Figma design constants (for layout reference)
|
||||
const FIGMA_PANEL_WIDTH_PX = 450;
|
||||
const FIGMA_PANEL_HEIGHT_PX = 132;
|
||||
const FIGMA_CORNER_RADIUS_PX = 30;
|
||||
const FIGMA_TITLE_FONT_SIZE_PX = 14;
|
||||
const FIGMA_TITLE_MARGIN_TOP_PX = 20;
|
||||
const FIGMA_SEEK_BAR_WIDTH_PX = 386;
|
||||
const FIGMA_SEEK_BAR_HEIGHT_PX = 5;
|
||||
const FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX = (FIGMA_PANEL_HEIGHT_PX / 2) - 54;
|
||||
|
||||
const FIGMA_PLAYPAUSE_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_X_PX = 225;
|
||||
const FIGMA_PLAYPAUSE_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_REWIND_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_REWIND_BUTTON_X_PX = 169;
|
||||
const FIGMA_REWIND_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_FORWARD_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_FORWARD_BUTTON_X_PX = 281;
|
||||
const FIGMA_FORWARD_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_EXIT_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_EXIT_BUTTON_X_PX = 42;
|
||||
const FIGMA_EXIT_BUTTON_Y_PX = 90;
|
||||
|
||||
const FIGMA_VOLUME_BUTTON_SIZE_PX = 44;
|
||||
const FIGMA_VOLUME_BUTTON_X_PX = 408;
|
||||
const FIGMA_VOLUME_BUTTON_Y_PX = 90;
|
||||
|
||||
|
||||
// World space dimensions derived from Figma constants
|
||||
const WORLD_PANEL_WIDTH = 1.5;
|
||||
const SCALE_FACTOR = WORLD_PANEL_WIDTH / FIGMA_PANEL_WIDTH_PX;
|
||||
const WORLD_PANEL_HEIGHT = FIGMA_PANEL_HEIGHT_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_WIDTH = FIGMA_SEEK_BAR_WIDTH_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_TRACK_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX) * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_PROGRESS_HEIGHT = (FIGMA_SEEK_BAR_HEIGHT_PX - 1) * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_Y_OFFSET = FIGMA_SEEK_BAR_Y_OFFSET_FROM_PANEL_CENTER_PX * SCALE_FACTOR;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER = 5;
|
||||
const WORLD_SEEK_BAR_HIT_AREA_HEIGHT = WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER;
|
||||
|
||||
const PANEL_TEXTURE_WIDTH = 1024;
|
||||
const PANEL_TEXTURE_HEIGHT = Math.round(PANEL_TEXTURE_WIDTH * (FIGMA_PANEL_HEIGHT_PX / FIGMA_PANEL_WIDTH_PX));
|
||||
let fallbackCameraControls: FallbackCameraControls | undefined;
|
||||
|
||||
// Panel fade animation variables
|
||||
let panelOpacity = 0;
|
||||
@@ -116,17 +67,6 @@ let lastFadeTimestamp = 0;
|
||||
const FADE_DURATION_MS = 200;
|
||||
const AUTO_HIDE_DELAY_MS = 10000;
|
||||
|
||||
// VR UI Meshes
|
||||
let seekBarTrackMesh, seekBarProgressMesh, seekBarHitAreaMesh;
|
||||
let vrPlayPauseButtonMesh, vrPlayPauseButtonCanvas, vrPlayPauseButtonContext, vrPlayPauseButtonTexture;
|
||||
let vrRewindButtonMesh;
|
||||
let vrForwardButtonMesh;
|
||||
let vrExitButtonMesh;
|
||||
let vrVolumeButtonMesh, vrVolumeButtonCanvas, vrVolumeButtonContext, vrVolumeButtonTexture;
|
||||
const VR_BUTTON_TEXTURE_SIZE = 128;
|
||||
|
||||
|
||||
|
||||
injectPlayerStyles(_playerBase);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -225,6 +165,12 @@ function createVideoTexture() {
|
||||
return videoTexture;
|
||||
}
|
||||
|
||||
function getVideoTitle() {
|
||||
return videoElement.getAttribute('title') ||
|
||||
videoElement.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||
"Video Title";
|
||||
}
|
||||
|
||||
|
||||
function init() {
|
||||
try {
|
||||
@@ -317,6 +263,11 @@ function init() {
|
||||
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, {
|
||||
hideControls: hide2DControlPanel,
|
||||
isEnabled: () => is2DMode,
|
||||
showControls: show2DControlPanel
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("INIT_ERROR (Phase 1 - Core Setup):", e);
|
||||
renderer = null;
|
||||
@@ -324,170 +275,10 @@ function init() {
|
||||
}
|
||||
|
||||
try { // Phase 2: VR Control Panel UI
|
||||
vrControlPanel = new THREE.Group();
|
||||
vrControlPanel.position.set(0, 0.5, -1.8);
|
||||
vrControlPanel.rotation.x = 0;
|
||||
scene.add(vrControlPanel);
|
||||
vrPanel = createVrControlPanel(scene, getVideoTitle());
|
||||
vrControlPanel = vrPanel.group;
|
||||
uiElements.push(...vrPanel.interactables);
|
||||
|
||||
const panelCanvas = document.createElement('canvas');
|
||||
panelCanvas.width = PANEL_TEXTURE_WIDTH;
|
||||
panelCanvas.height = PANEL_TEXTURE_HEIGHT;
|
||||
const panelCtx = panelCanvas.getContext('2d');
|
||||
|
||||
panelCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
const textureCornerRadius = FIGMA_CORNER_RADIUS_PX * (PANEL_TEXTURE_WIDTH / FIGMA_PANEL_WIDTH_PX);
|
||||
drawRoundedRect(panelCtx, 0, 0, PANEL_TEXTURE_WIDTH, PANEL_TEXTURE_HEIGHT, textureCornerRadius, true, false);
|
||||
|
||||
const videoTitle = videoElement.getAttribute('title') || videoElement.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') || "Video Title";
|
||||
const titleFontSizeTexturePx = Math.round(FIGMA_TITLE_FONT_SIZE_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX));
|
||||
panelCtx.fillStyle = '#ffffff';
|
||||
panelCtx.font = `500 ${titleFontSizeTexturePx}px Helvetica, Arial, sans-serif`;
|
||||
panelCtx.textAlign = 'center';
|
||||
panelCtx.textBaseline = 'top';
|
||||
const titleMarginTopTexturePx = FIGMA_TITLE_MARGIN_TOP_PX * (PANEL_TEXTURE_HEIGHT / FIGMA_PANEL_HEIGHT_PX);
|
||||
panelCtx.fillText(videoTitle, PANEL_TEXTURE_WIDTH / 2, titleMarginTopTexturePx);
|
||||
|
||||
const panelTexture = new THREE.CanvasTexture(panelCanvas);
|
||||
panelTexture.minFilter = THREE.LinearFilter;
|
||||
panelTexture.needsUpdate = true;
|
||||
|
||||
const panelMaterial = new THREE.MeshBasicMaterial({
|
||||
map: panelTexture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
const panelGeometry = new THREE.PlaneGeometry(WORLD_PANEL_WIDTH, WORLD_PANEL_HEIGHT);
|
||||
const panelMesh = new THREE.Mesh(panelGeometry, panelMaterial);
|
||||
panelMesh.name = "vrControlPanelBackground";
|
||||
panelMesh.renderOrder = 0;
|
||||
vrControlPanel.add(panelMesh);
|
||||
uiElements.push(panelMesh);
|
||||
|
||||
const seekBarTrackMaterial = new THREE.MeshBasicMaterial({ color: 0x767676, transparent: true, opacity: 0 });
|
||||
const seekBarTrackGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT);
|
||||
seekBarTrackMesh = new THREE.Mesh(seekBarTrackGeometry, seekBarTrackMaterial);
|
||||
seekBarTrackMesh.name = "seekBarTrackVisual";
|
||||
seekBarTrackMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarTrackMesh.position.z = 0.01;
|
||||
seekBarTrackMesh.renderOrder = 1;
|
||||
vrControlPanel.add(seekBarTrackMesh);
|
||||
|
||||
const seekBarProgressMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 });
|
||||
const seekBarProgressGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_PROGRESS_HEIGHT);
|
||||
seekBarProgressMesh = new THREE.Mesh(seekBarProgressGeometry, seekBarProgressMaterial);
|
||||
seekBarProgressMesh.name = "seekBarProgressVisual";
|
||||
seekBarProgressMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET + (1 * SCALE_FACTOR);
|
||||
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
seekBarProgressMesh.position.z = 0.015;
|
||||
seekBarProgressMesh.scale.x = 0.001;
|
||||
seekBarProgressMesh.renderOrder = 2;
|
||||
vrControlPanel.add(seekBarProgressMesh);
|
||||
|
||||
const seekBarHitAreaGeometry = new THREE.PlaneGeometry(WORLD_SEEK_BAR_WIDTH, WORLD_SEEK_BAR_TRACK_HEIGHT * WORLD_SEEK_BAR_HIT_AREA_HEIGHT_MULTIPLIER);
|
||||
const seekBarHitAreaMaterial = new THREE.MeshBasicMaterial({ visible: false, transparent: true, opacity: 0 });
|
||||
seekBarHitAreaMesh = new THREE.Mesh(seekBarHitAreaGeometry, seekBarHitAreaMaterial);
|
||||
seekBarHitAreaMesh.name = "seekBarHitArea";
|
||||
seekBarHitAreaMesh.position.y = WORLD_SEEK_BAR_Y_OFFSET;
|
||||
seekBarHitAreaMesh.position.z = 0.012;
|
||||
seekBarHitAreaMesh.renderOrder = 2;
|
||||
vrControlPanel.add(seekBarHitAreaMesh);
|
||||
uiElements.push(seekBarHitAreaMesh);
|
||||
|
||||
vrPlayPauseButtonCanvas = document.createElement('canvas');
|
||||
vrPlayPauseButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
vrPlayPauseButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
vrPlayPauseButtonContext = vrPlayPauseButtonCanvas.getContext('2d');
|
||||
vrPlayPauseButtonTexture = new THREE.CanvasTexture(vrPlayPauseButtonCanvas);
|
||||
vrPlayPauseButtonTexture.minFilter = THREE.LinearFilter;
|
||||
|
||||
const playPauseButtonMaterial = new THREE.MeshBasicMaterial({
|
||||
map: vrPlayPauseButtonTexture,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
const playPauseButtonWorldSize = FIGMA_PLAYPAUSE_BUTTON_SIZE_PX * SCALE_FACTOR;
|
||||
const playPauseButtonGeometry = new THREE.PlaneGeometry(playPauseButtonWorldSize, playPauseButtonWorldSize);
|
||||
vrPlayPauseButtonMesh = new THREE.Mesh(playPauseButtonGeometry, playPauseButtonMaterial);
|
||||
vrPlayPauseButtonMesh.name = "vrPlayPauseButton";
|
||||
vrPlayPauseButtonMesh.renderOrder = 3;
|
||||
|
||||
const figmaPlayPauseButtonCenterX = FIGMA_PLAYPAUSE_BUTTON_X_PX;
|
||||
const figmaPlayPauseButtonCenterY = FIGMA_PLAYPAUSE_BUTTON_Y_PX;
|
||||
const worldPlayPauseButtonOffsetX = (figmaPlayPauseButtonCenterX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldPlayPauseButtonOffsetY = -(figmaPlayPauseButtonCenterY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
vrPlayPauseButtonMesh.position.set(worldPlayPauseButtonOffsetX, worldPlayPauseButtonOffsetY, 0.02);
|
||||
vrControlPanel.add(vrPlayPauseButtonMesh);
|
||||
uiElements.push(vrPlayPauseButtonMesh);
|
||||
|
||||
const rewindButtonWorldSize = FIGMA_REWIND_BUTTON_SIZE_PX * SCALE_FACTOR;
|
||||
const rewindButtonTexture = createLucideButtonTexture('rotate-ccw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, 82, '15');
|
||||
const rewindButtonMaterial = new THREE.MeshBasicMaterial({ map: rewindButtonTexture, transparent: true, opacity: 0, depthWrite: false });
|
||||
const rewindButtonGeometry = new THREE.PlaneGeometry(rewindButtonWorldSize, rewindButtonWorldSize);
|
||||
vrRewindButtonMesh = new THREE.Mesh(rewindButtonGeometry, rewindButtonMaterial);
|
||||
vrRewindButtonMesh.name = "vrRewindButton";
|
||||
vrRewindButtonMesh.renderOrder = 3;
|
||||
const figmaRewindButtonCenterX = FIGMA_REWIND_BUTTON_X_PX;
|
||||
const figmaRewindButtonCenterY = FIGMA_REWIND_BUTTON_Y_PX;
|
||||
const worldRewindButtonOffsetX = (figmaRewindButtonCenterX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldRewindButtonOffsetY = -(figmaRewindButtonCenterY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
vrRewindButtonMesh.position.set(worldRewindButtonOffsetX, worldRewindButtonOffsetY, 0.02);
|
||||
vrControlPanel.add(vrRewindButtonMesh);
|
||||
uiElements.push(vrRewindButtonMesh);
|
||||
|
||||
const forwardButtonWorldSize = FIGMA_FORWARD_BUTTON_SIZE_PX * SCALE_FACTOR;
|
||||
const forwardButtonTexture = createLucideButtonTexture('rotate-cw', '#ffffff', VR_BUTTON_TEXTURE_SIZE, 82, '15');
|
||||
const forwardButtonMaterial = new THREE.MeshBasicMaterial({ map: forwardButtonTexture, transparent: true, opacity: 0, depthWrite: false });
|
||||
const forwardButtonGeometry = new THREE.PlaneGeometry(forwardButtonWorldSize, forwardButtonWorldSize);
|
||||
vrForwardButtonMesh = new THREE.Mesh(forwardButtonGeometry, forwardButtonMaterial);
|
||||
vrForwardButtonMesh.name = "vrForwardButton";
|
||||
vrForwardButtonMesh.renderOrder = 3;
|
||||
const figmaForwardButtonCenterX = FIGMA_FORWARD_BUTTON_X_PX;
|
||||
const figmaForwardButtonCenterY = FIGMA_FORWARD_BUTTON_Y_PX;
|
||||
const worldForwardButtonOffsetX = (figmaForwardButtonCenterX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldForwardButtonOffsetY = -(figmaForwardButtonCenterY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
vrForwardButtonMesh.position.set(worldForwardButtonOffsetX, worldForwardButtonOffsetY, 0.02);
|
||||
vrControlPanel.add(vrForwardButtonMesh);
|
||||
uiElements.push(vrForwardButtonMesh);
|
||||
|
||||
const exitButtonWorldSize = FIGMA_EXIT_BUTTON_SIZE_PX * SCALE_FACTOR;
|
||||
const exitButtonTexture = createLucideButtonTexture('log-out', '#ffffff', VR_BUTTON_TEXTURE_SIZE, 82);
|
||||
const exitButtonMaterial = new THREE.MeshBasicMaterial({ map: exitButtonTexture, transparent: true, opacity: 0, depthWrite: false });
|
||||
const exitButtonGeometry = new THREE.PlaneGeometry(exitButtonWorldSize, exitButtonWorldSize);
|
||||
vrExitButtonMesh = new THREE.Mesh(exitButtonGeometry, exitButtonMaterial);
|
||||
vrExitButtonMesh.name = "vrExitButton";
|
||||
vrExitButtonMesh.renderOrder = 3;
|
||||
const figmaExitButtonCenterX = FIGMA_EXIT_BUTTON_X_PX;
|
||||
const figmaExitButtonCenterY = FIGMA_EXIT_BUTTON_Y_PX;
|
||||
const worldExitButtonOffsetX = (figmaExitButtonCenterX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldExitButtonOffsetY = -(figmaExitButtonCenterY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
vrExitButtonMesh.position.set(worldExitButtonOffsetX, worldExitButtonOffsetY, 0.02);
|
||||
vrControlPanel.add(vrExitButtonMesh);
|
||||
uiElements.push(vrExitButtonMesh);
|
||||
|
||||
vrVolumeButtonCanvas = document.createElement('canvas');
|
||||
vrVolumeButtonCanvas.width = VR_BUTTON_TEXTURE_SIZE;
|
||||
vrVolumeButtonCanvas.height = VR_BUTTON_TEXTURE_SIZE;
|
||||
vrVolumeButtonContext = vrVolumeButtonCanvas.getContext('2d');
|
||||
vrVolumeButtonTexture = new THREE.CanvasTexture(vrVolumeButtonCanvas);
|
||||
vrVolumeButtonTexture.minFilter = THREE.LinearFilter;
|
||||
const volumeButtonMaterial = new THREE.MeshBasicMaterial({ map: vrVolumeButtonTexture, transparent: true, opacity: 0, depthWrite: false });
|
||||
const volumeButtonWorldSize = FIGMA_VOLUME_BUTTON_SIZE_PX * SCALE_FACTOR;
|
||||
const volumeButtonGeometry = new THREE.PlaneGeometry(volumeButtonWorldSize, volumeButtonWorldSize);
|
||||
vrVolumeButtonMesh = new THREE.Mesh(volumeButtonGeometry, volumeButtonMaterial);
|
||||
vrVolumeButtonMesh.name = "vrVolumeButton";
|
||||
vrVolumeButtonMesh.renderOrder = 3;
|
||||
const figmaVolumeButtonCenterX = FIGMA_VOLUME_BUTTON_X_PX;
|
||||
const figmaVolumeButtonCenterY = FIGMA_VOLUME_BUTTON_Y_PX;
|
||||
const worldVolumeButtonOffsetX = (figmaVolumeButtonCenterX - FIGMA_PANEL_WIDTH_PX / 2) * SCALE_FACTOR;
|
||||
const worldVolumeButtonOffsetY = -(figmaVolumeButtonCenterY - FIGMA_PANEL_HEIGHT_PX / 2) * SCALE_FACTOR;
|
||||
vrVolumeButtonMesh.position.set(worldVolumeButtonOffsetX, worldVolumeButtonOffsetY, 0.02);
|
||||
vrControlPanel.add(vrVolumeButtonMesh);
|
||||
uiElements.push(vrVolumeButtonMesh);
|
||||
|
||||
vrControlPanel.visible = false;
|
||||
panelOpacity = 0;
|
||||
panelTargetOpacity = 0;
|
||||
|
||||
@@ -561,40 +352,24 @@ function init() {
|
||||
|
||||
|
||||
function updateVRPlayPauseButtonIcon() {
|
||||
if (!vrPlayPauseButtonContext || !vrPlayPauseButtonTexture || !video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
const ctx = vrPlayPauseButtonContext;
|
||||
const canvas = vrPlayPauseButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconSize = 82;
|
||||
const iconOffset = (canvas.width - iconSize) / 2;
|
||||
drawLucideIcon(ctx, video.paused || video.ended ? 'play' : 'pause', iconOffset, iconOffset, iconSize, '#ffffff', 2);
|
||||
vrPlayPauseButtonTexture.needsUpdate = true;
|
||||
updateVrPlayPauseButtonIcon(vrPanel, video.paused || video.ended);
|
||||
}
|
||||
|
||||
function updateVRVolumeButtonIcon() {
|
||||
if (!vrVolumeButtonContext || !vrVolumeButtonTexture || !video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
const ctx = vrVolumeButtonContext;
|
||||
const canvas = vrVolumeButtonCanvas;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const iconSize = 82;
|
||||
const iconOffset = (canvas.width - iconSize) / 2;
|
||||
drawLucideIcon(ctx, video.muted || video.volume === 0 ? 'volume-x' : 'volume-2', iconOffset, iconOffset, iconSize, '#ffffff', 2);
|
||||
vrVolumeButtonTexture.needsUpdate = true;
|
||||
updateVrVolumeButtonIcon(vrPanel, video.muted || video.volume === 0);
|
||||
}
|
||||
|
||||
function updateSeekBarAppearance() {
|
||||
if (video && isFinite(video.duration) && video.duration > 0 && seekBarProgressMesh) {
|
||||
const progress = video.currentTime / video.duration;
|
||||
seekBarProgressMesh.scale.x = Math.max(0.0001, progress);
|
||||
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2 + (WORLD_SEEK_BAR_WIDTH * progress) / 2;
|
||||
} else if (seekBarProgressMesh) {
|
||||
seekBarProgressMesh.scale.x = 0.0001;
|
||||
seekBarProgressMesh.position.x = -WORLD_SEEK_BAR_WIDTH / 2;
|
||||
}
|
||||
const progress = video && isFinite(video.duration) && video.duration > 0
|
||||
? video.currentTime / video.duration
|
||||
: null;
|
||||
updateVrSeekBarAppearance(vrPanel, progress);
|
||||
}
|
||||
|
||||
function animatePanelFade(timestamp) {
|
||||
@@ -623,11 +398,7 @@ function animatePanelFade(timestamp) {
|
||||
isPanelFading = false;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
vrControlPanel.children.forEach(child => {
|
||||
if (child.material && child.material.hasOwnProperty('opacity')) {
|
||||
child.material.opacity = panelOpacity;
|
||||
}
|
||||
});
|
||||
setVrPanelOpacity(vrPanel, panelOpacity);
|
||||
}
|
||||
if (isPanelFading) requestAnimationFrame(animatePanelFade);
|
||||
}
|
||||
@@ -710,113 +481,12 @@ function onWindowResize() {
|
||||
}
|
||||
}
|
||||
|
||||
// 2D Camera Control Functions
|
||||
function updateCameraRotation() {
|
||||
if (!camera2D) return;
|
||||
|
||||
// Apply momentum
|
||||
cameraRotation.yaw += cameraVelocity.yaw;
|
||||
cameraRotation.pitch += cameraVelocity.pitch;
|
||||
|
||||
// Constrain pitch (vertical rotation)
|
||||
cameraRotation.pitch = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, cameraRotation.pitch));
|
||||
|
||||
// Constrain yaw (horizontal rotation) to VR180 content boundaries
|
||||
cameraRotation.yaw = Math.max(-MAX_YAW, Math.min(MAX_YAW, cameraRotation.yaw));
|
||||
|
||||
// Apply damping to velocity
|
||||
cameraVelocity.yaw *= MOMENTUM_DAMPING;
|
||||
cameraVelocity.pitch *= MOMENTUM_DAMPING;
|
||||
|
||||
// Stop very small velocities
|
||||
if (Math.abs(cameraVelocity.yaw) < 0.001) cameraVelocity.yaw = 0;
|
||||
if (Math.abs(cameraVelocity.pitch) < 0.001) cameraVelocity.pitch = 0;
|
||||
|
||||
// Apply rotation to camera
|
||||
camera2D.rotation.set(cameraRotation.pitch, cameraRotation.yaw, 0);
|
||||
}
|
||||
|
||||
// Mouse Controls
|
||||
function onMouseDown(event) {
|
||||
if (!is2DMode) return;
|
||||
isDragging = true;
|
||||
lastMouseX = event.clientX;
|
||||
lastMouseY = event.clientY;
|
||||
cameraVelocity.yaw = 0;
|
||||
cameraVelocity.pitch = 0;
|
||||
|
||||
// Hide controls when dragging starts
|
||||
hide2DControlPanel();
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (!is2DMode || !isDragging) return;
|
||||
|
||||
const deltaX = event.clientX - lastMouseX;
|
||||
const deltaY = event.clientY - lastMouseY;
|
||||
|
||||
cameraVelocity.yaw = deltaX * MOUSE_SENSITIVITY;
|
||||
cameraVelocity.pitch = deltaY * MOUSE_SENSITIVITY;
|
||||
|
||||
lastMouseX = event.clientX;
|
||||
lastMouseY = event.clientY;
|
||||
}
|
||||
|
||||
function onMouseUp(event) {
|
||||
if (!is2DMode) return;
|
||||
isDragging = false;
|
||||
|
||||
// Show controls when dragging ends
|
||||
show2DControlPanel();
|
||||
}
|
||||
|
||||
// Touch Controls
|
||||
function onTouchStart(event) {
|
||||
if (!is2DMode) return;
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
isDragging = true;
|
||||
lastTouchX = event.touches[0].clientX;
|
||||
lastTouchY = event.touches[0].clientY;
|
||||
cameraVelocity.yaw = 0;
|
||||
cameraVelocity.pitch = 0;
|
||||
|
||||
// Hide controls when dragging starts
|
||||
hide2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(event) {
|
||||
if (!is2DMode || !isDragging) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (event.touches.length === 1) {
|
||||
const deltaX = event.touches[0].clientX - lastTouchX;
|
||||
const deltaY = event.touches[0].clientY - lastTouchY;
|
||||
|
||||
cameraVelocity.yaw = deltaX * TOUCH_SENSITIVITY;
|
||||
cameraVelocity.pitch = deltaY * TOUCH_SENSITIVITY;
|
||||
|
||||
lastTouchX = event.touches[0].clientX;
|
||||
lastTouchY = event.touches[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd(event) {
|
||||
if (!is2DMode) return;
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
|
||||
// Show controls when dragging ends
|
||||
show2DControlPanel();
|
||||
}
|
||||
|
||||
// 2D Render Loop
|
||||
function render2D() {
|
||||
if (!is2DMode) return;
|
||||
|
||||
if (projectionMode === 'vr180') {
|
||||
updateCameraRotation();
|
||||
fallbackCameraControls?.updateCameraRotation();
|
||||
} else if (camera2D) {
|
||||
camera2D.rotation.set(0, 0, 0);
|
||||
}
|
||||
@@ -850,10 +520,7 @@ function init2DControlPanel() {
|
||||
|
||||
// Set initial video title
|
||||
if (videoTitle && video) {
|
||||
const title = video.getAttribute('title') ||
|
||||
video.querySelector('source')?.src.split('/').pop()?.split('.')[0].replace(/-/g, ' ') ||
|
||||
"Demo Video";
|
||||
videoTitle.textContent = title;
|
||||
videoTitle.textContent = getVideoTitle();
|
||||
}
|
||||
|
||||
// Add event listeners for 2D controls
|
||||
@@ -926,30 +593,6 @@ function hide2DControlPanel() {
|
||||
isControlPanelVisible = false;
|
||||
}
|
||||
|
||||
function onCanvasMouseMove() {
|
||||
if (is2DMode && !isDragging) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasTouchStart() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function on2DMouseMove() {
|
||||
if (is2DMode) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function on2DTouchStart() {
|
||||
if (is2DMode && !isDragging) {
|
||||
show2DControlPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function update2DControlPanel() {
|
||||
if (!is2DMode || !video) return;
|
||||
|
||||
@@ -1062,20 +705,6 @@ function position2DControlPanel() {
|
||||
controlPanel.style.zIndex = '1000'; // Ensure it's above the canvas
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!isFinite(seconds)) return '00:00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function hidePlayButton() {
|
||||
if (playBtn) {
|
||||
playBtn.classList.add('hidden');
|
||||
@@ -1140,9 +769,7 @@ function resetToOriginalState() {
|
||||
hide2DControlPanel();
|
||||
|
||||
// Reset camera rotation
|
||||
cameraRotation = { yaw: 0, pitch: 0 };
|
||||
cameraVelocity = { yaw: 0, pitch: 0 };
|
||||
isDragging = false;
|
||||
fallbackCameraControls?.reset();
|
||||
positionPlaneForPresentation(false);
|
||||
|
||||
// Hide WebGL canvas and show video element
|
||||
@@ -1212,9 +839,7 @@ function onSelectStartVR(event) {
|
||||
showPanel();
|
||||
} else if (firstIntersected.name === "seekBarHitArea" && video && isFinite(video.duration)) {
|
||||
showPanel();
|
||||
const localPoint = seekBarTrackMesh.worldToLocal(intersectionPoint.clone());
|
||||
const normalizedPosition = (localPoint.x + WORLD_SEEK_BAR_WIDTH / 2) / WORLD_SEEK_BAR_WIDTH;
|
||||
const newTime = Math.max(0, Math.min(1, normalizedPosition)) * video.duration;
|
||||
const newTime = getSeekProgressFromIntersection(vrPanel, intersectionPoint) * video.duration;
|
||||
video.currentTime = newTime;
|
||||
updateSeekBarAppearance();
|
||||
} else if (firstIntersected.name === "vrControlPanelBackground" || firstIntersected === activeContentMesh) {
|
||||
@@ -1322,39 +947,13 @@ function start2DMode() {
|
||||
}
|
||||
|
||||
function add2DEventListeners() {
|
||||
// Canvas-specific mouse movement for showing controls
|
||||
renderer.domElement.addEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
if (projectionMode === 'vr180') {
|
||||
// Mouse events
|
||||
renderer.domElement.addEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Touch events
|
||||
renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
|
||||
renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
renderer.domElement.addEventListener('touchend', onTouchEnd, { passive: false });
|
||||
} else {
|
||||
renderer.domElement.addEventListener('touchstart', onCanvasTouchStart, { passive: true });
|
||||
}
|
||||
fallbackCameraControls?.addEventListeners(renderer.domElement, projectionMode);
|
||||
}
|
||||
|
||||
function remove2DEventListeners() {
|
||||
if (!renderer || !renderer.domElement) return;
|
||||
|
||||
renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
|
||||
// Mouse events
|
||||
renderer.domElement.removeEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.removeEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Touch events
|
||||
renderer.domElement.removeEventListener('touchstart', onTouchStart);
|
||||
renderer.domElement.removeEventListener('touchmove', onTouchMove);
|
||||
renderer.domElement.removeEventListener('touchend', onTouchEnd);
|
||||
renderer.domElement.removeEventListener('touchstart', onCanvasTouchStart);
|
||||
fallbackCameraControls?.removeEventListeners(renderer.domElement);
|
||||
|
||||
// Fullscreen events
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
@@ -1386,12 +985,7 @@ async function actualSessionToggle() {
|
||||
clearTimeout(panelHideTimeout);
|
||||
panelTargetOpacity = 0;
|
||||
panelOpacity = 0;
|
||||
vrControlPanel.children.forEach(child => {
|
||||
if (child.material && child.material.hasOwnProperty('opacity')) {
|
||||
child.material.opacity = 0;
|
||||
}
|
||||
});
|
||||
vrControlPanel.visible = false;
|
||||
hideVrPanelImmediately(vrPanel);
|
||||
isPanelFading = false;
|
||||
}
|
||||
sessionToClose.end().catch(err => {
|
||||
@@ -1440,14 +1034,9 @@ async function actualSessionToggle() {
|
||||
updateVRPlayPauseButtonIcon();
|
||||
updateVRVolumeButtonIcon();
|
||||
if (vrControlPanel) {
|
||||
vrControlPanel.visible = false;
|
||||
panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false;
|
||||
clearTimeout(panelHideTimeout);
|
||||
vrControlPanel.children.forEach(child => {
|
||||
if (child.material && child.material.hasOwnProperty('opacity')) {
|
||||
child.material.opacity = 0;
|
||||
}
|
||||
});
|
||||
hideVrPanelImmediately(vrPanel);
|
||||
}
|
||||
|
||||
await renderer.xr.setSession(xrSession);
|
||||
@@ -1465,8 +1054,9 @@ async function actualSessionToggle() {
|
||||
if (sphereMaterial) { sphereMaterial.map = null; sphereMaterial.needsUpdate = true; }
|
||||
if (videoTexture) { videoTexture.dispose(); videoTexture = null; }
|
||||
if (vrControlPanel) {
|
||||
vrControlPanel.visible = false; panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false;
|
||||
panelOpacity = 0; panelTargetOpacity = 0; isPanelFading = false;
|
||||
clearTimeout(panelHideTimeout);
|
||||
hideVrPanelImmediately(vrPanel);
|
||||
}
|
||||
if (xrSession) {
|
||||
xrSession.removeEventListener('end', onVRSessionEnd);
|
||||
@@ -1517,12 +1107,7 @@ function onVRSessionEnd(event) {
|
||||
clearTimeout(panelHideTimeout);
|
||||
isPanelFading = false;
|
||||
panelOpacity = 0; panelTargetOpacity = 0;
|
||||
vrControlPanel.children.forEach(child => {
|
||||
if (child.material && child.material.hasOwnProperty('opacity')) {
|
||||
child.material.opacity = 0;
|
||||
}
|
||||
});
|
||||
vrControlPanel.visible = false;
|
||||
hideVrPanelImmediately(vrPanel);
|
||||
}
|
||||
|
||||
if (endedSession && typeof endedSession.removeEventListener === 'function') {
|
||||
|
||||
Reference in New Issue
Block a user