/** * CloudXR2DUI.tsx - CloudXR 2D User Interface Management * * This class handles all the HTML form interactions, localStorage persistence, * and form validation for the CloudXR React example. It follows the same pattern * as the simple example's CloudXRWebUI class, providing a clean separation * between UI management and React component logic. * * Features: * - Form field management and localStorage persistence * - Proxy configuration based on protocol * - Form validation and default value handling * - Event listener management * - Error handling and logging */ import { CloudXRConfig, enableLocalStorage, setupCertificateAcceptanceLink } from '@helpers/utils'; /** * 2D UI Management for CloudXR React Example * Handles the main user interface for CloudXR streaming, including form management, * localStorage persistence, and user interaction controls. */ export class CloudXR2DUI { /** Button to initiate XR streaming session */ private startButton!: HTMLButtonElement; /** Input field for the CloudXR server IP address */ private serverIpInput!: HTMLInputElement; /** Input field for the CloudXR server port number */ private portInput!: HTMLInputElement; /** Input field for proxy URL configuration */ private proxyUrlInput!: HTMLInputElement; /** Dropdown to select between AR and VR immersive modes */ private immersiveSelect!: HTMLSelectElement; /** Dropdown to select device frame rate (FPS) */ private deviceFrameRateSelect!: HTMLSelectElement; /** Dropdown to select max streaming bitrate (Mbps) */ private maxStreamingBitrateMbpsSelect!: HTMLSelectElement; /** Input field for per-eye width configuration */ private perEyeWidthInput!: HTMLInputElement; /** Input field for per-eye height configuration */ private perEyeHeightInput!: HTMLInputElement; /** Dropdown to select server backend type */ private serverTypeSelect!: HTMLSelectElement; /** Dropdown to select application type */ private appSelect!: HTMLSelectElement; /** Dropdown to select reference space for XR tracking */ private referenceSpaceSelect!: HTMLSelectElement; /** Input for XR reference space X offset (cm) */ private xrOffsetXInput!: HTMLInputElement; /** Input for XR reference space Y offset (cm) */ private xrOffsetYInput!: HTMLInputElement; /** Input for XR reference space Z offset (cm) */ private xrOffsetZInput!: HTMLInputElement; /** Text element displaying proxy configuration help */ private proxyDefaultText!: HTMLElement; /** Error message box element */ private errorMessageBox!: HTMLElement; /** Error message text element */ private errorMessageText!: HTMLElement; /** Certificate acceptance link container */ private certAcceptanceLink!: HTMLElement; /** Certificate acceptance link anchor */ private certLink!: HTMLAnchorElement; /** Flag to track if the 2D UI has been initialized */ private initialized: boolean = false; /** Current form configuration state */ private currentConfiguration: CloudXRConfig; /** Callback function for configuration changes */ private onConfigurationChange: ((config: CloudXRConfig) => void) | null = null; /** Connect button click handler for cleanup */ private handleConnectClick: ((event: Event) => void) | null = null; /** Array to store all event listeners for proper cleanup */ private eventListeners: Array<{ element: HTMLElement; event: string; handler: EventListener; }> = []; /** Cleanup function for certificate acceptance link */ private certLinkCleanup: (() => void) | null = null; /** * Creates a new CloudXR2DUI instance * @param onConfigurationChange - Callback function called when configuration changes */ constructor(onConfigurationChange?: (config: CloudXRConfig) => void) { this.onConfigurationChange = onConfigurationChange || null; this.currentConfiguration = this.getDefaultConfiguration(); } /** * Initializes the CloudXR2DUI with all necessary components and event handlers */ public initialize(): void { if (this.initialized) { return; } try { this.initializeElements(); this.setupLocalStorage(); this.setupProxyConfiguration(); this.setupEventListeners(); this.updateConfiguration(); this.setStartButtonState(false, 'CONNECT'); this.initialized = true; } catch (error) { // Continue with default values if initialization fails this.showError(`Failed to initialize CloudXR2DUI: ${error}`); } } /** * Initializes all DOM element references by their IDs * Throws an error if any required element is not found */ private initializeElements(): void { this.startButton = this.getElement('startButton'); this.serverIpInput = this.getElement('serverIpInput'); this.portInput = this.getElement('portInput'); this.proxyUrlInput = this.getElement('proxyUrl'); this.immersiveSelect = this.getElement('immersive'); this.deviceFrameRateSelect = this.getElement('deviceFrameRate'); this.maxStreamingBitrateMbpsSelect = this.getElement('maxStreamingBitrateMbps'); this.perEyeWidthInput = this.getElement('perEyeWidth'); this.perEyeHeightInput = this.getElement('perEyeHeight'); this.serverTypeSelect = this.getElement('serverType'); this.appSelect = this.getElement('app'); this.referenceSpaceSelect = this.getElement('referenceSpace'); this.xrOffsetXInput = this.getElement('xrOffsetX'); this.xrOffsetYInput = this.getElement('xrOffsetY'); this.xrOffsetZInput = this.getElement('xrOffsetZ'); this.proxyDefaultText = this.getElement('proxyDefaultText'); this.errorMessageBox = this.getElement('errorMessageBox'); this.errorMessageText = this.getElement('errorMessageText'); this.certAcceptanceLink = this.getElement('certAcceptanceLink'); this.certLink = this.getElement('certLink'); } /** * Gets a DOM element by ID with type safety * @param id - The element ID to find * @returns The found element with the specified type * @throws Error if element is not found */ private getElement(id: string): T { const element = document.getElementById(id) as T; if (!element) { throw new Error(`Element with id '${id}' not found`); } return element; } /** * Gets the default configuration values * @returns Default configuration object */ private getDefaultConfiguration(): CloudXRConfig { const useSecure = typeof window !== 'undefined' ? window.location.protocol === 'https:' : false; // Default port: HTTP → 49100, HTTPS without proxy → 48322, HTTPS with proxy → 443 const defaultPort = useSecure ? 48322 : 49100; return { serverIP: '127.0.0.1', port: defaultPort, useSecureConnection: useSecure, perEyeWidth: 2048, perEyeHeight: 1792, deviceFrameRate: 90, maxStreamingBitrateMbps: 150, immersiveMode: 'ar', app: 'generic', serverType: 'manual', proxyUrl: '', referenceSpaceType: 'auto', }; } /** * Enables localStorage persistence for form inputs * Automatically saves and restores user preferences */ private setupLocalStorage(): void { enableLocalStorage(this.serverTypeSelect, 'serverType'); enableLocalStorage(this.serverIpInput, 'serverIp'); enableLocalStorage(this.portInput, 'port'); enableLocalStorage(this.perEyeWidthInput, 'perEyeWidth'); enableLocalStorage(this.perEyeHeightInput, 'perEyeHeight'); enableLocalStorage(this.proxyUrlInput, 'proxyUrl'); enableLocalStorage(this.deviceFrameRateSelect, 'deviceFrameRate'); enableLocalStorage(this.maxStreamingBitrateMbpsSelect, 'maxStreamingBitrateMbps'); enableLocalStorage(this.immersiveSelect, 'immersiveMode'); enableLocalStorage(this.appSelect, 'app'); enableLocalStorage(this.referenceSpaceSelect, 'referenceSpace'); enableLocalStorage(this.xrOffsetXInput, 'xrOffsetX'); enableLocalStorage(this.xrOffsetYInput, 'xrOffsetY'); enableLocalStorage(this.xrOffsetZInput, 'xrOffsetZ'); } /** * Configures proxy settings based on the current protocol (HTTP/HTTPS) * Sets appropriate placeholders and help text for port and proxy URL inputs */ private setupProxyConfiguration(): void { // Update port placeholder based on protocol if (window.location.protocol === 'https:') { this.portInput.placeholder = 'Port (default: 48322, or 443 if proxy URL set)'; } else { this.portInput.placeholder = 'Port (default: 49100)'; } // Set default text and placeholder based on protocol if (window.location.protocol === 'https:') { this.proxyDefaultText.textContent = 'Optional: Leave empty for direct WSS connection, or provide URL for proxy routing (e.g., https://proxy.example.com/)'; this.proxyUrlInput.placeholder = ''; } else { this.proxyDefaultText.textContent = 'Not needed for HTTP - uses direct WS connection'; this.proxyUrlInput.placeholder = ''; } } /** * Sets up event listeners for form input changes * Handles both input and change events for better compatibility */ private setupEventListeners(): void { // Update configuration when form inputs change const updateConfig = () => this.updateConfiguration(); // Helper function to add listeners and store them for cleanup const addListener = (element: HTMLElement, event: string, handler: EventListener) => { element.addEventListener(event, handler); this.eventListeners.push({ element, event, handler }); }; // Add event listeners for all form fields addListener(this.serverTypeSelect, 'change', updateConfig); addListener(this.serverIpInput, 'input', updateConfig); addListener(this.serverIpInput, 'change', updateConfig); addListener(this.portInput, 'input', updateConfig); addListener(this.portInput, 'change', updateConfig); addListener(this.perEyeWidthInput, 'input', updateConfig); addListener(this.perEyeWidthInput, 'change', updateConfig); addListener(this.perEyeHeightInput, 'input', updateConfig); addListener(this.perEyeHeightInput, 'change', updateConfig); addListener(this.deviceFrameRateSelect, 'change', updateConfig); addListener(this.maxStreamingBitrateMbpsSelect, 'change', updateConfig); addListener(this.immersiveSelect, 'change', updateConfig); addListener(this.appSelect, 'change', updateConfig); addListener(this.referenceSpaceSelect, 'change', updateConfig); addListener(this.xrOffsetXInput, 'input', updateConfig); addListener(this.xrOffsetXInput, 'change', updateConfig); addListener(this.xrOffsetYInput, 'input', updateConfig); addListener(this.xrOffsetYInput, 'change', updateConfig); addListener(this.xrOffsetZInput, 'input', updateConfig); addListener(this.xrOffsetZInput, 'change', updateConfig); addListener(this.proxyUrlInput, 'input', updateConfig); addListener(this.proxyUrlInput, 'change', updateConfig); // Set up certificate acceptance link and store cleanup function this.certLinkCleanup = setupCertificateAcceptanceLink( this.serverIpInput, this.portInput, this.proxyUrlInput, this.certAcceptanceLink, this.certLink ); } /** * Updates the current configuration from form values * Calls the configuration change callback if provided */ private updateConfiguration(): void { const useSecure = this.getDefaultConfiguration().useSecureConnection; const portValue = parseInt(this.portInput.value); const hasProxy = this.proxyUrlInput.value.trim().length > 0; // Smart default port based on connection type and proxy usage let defaultPort = 49100; // HTTP default if (useSecure) { defaultPort = hasProxy ? 443 : 48322; // HTTPS with proxy → 443, HTTPS without → 48322 } const newConfiguration: CloudXRConfig = { serverIP: this.serverIpInput.value || this.getDefaultConfiguration().serverIP, port: portValue || defaultPort, useSecureConnection: useSecure, perEyeWidth: parseInt(this.perEyeWidthInput.value) || this.getDefaultConfiguration().perEyeWidth, perEyeHeight: parseInt(this.perEyeHeightInput.value) || this.getDefaultConfiguration().perEyeHeight, deviceFrameRate: parseInt(this.deviceFrameRateSelect.value) || this.getDefaultConfiguration().deviceFrameRate, maxStreamingBitrateMbps: parseInt(this.maxStreamingBitrateMbpsSelect.value) || this.getDefaultConfiguration().maxStreamingBitrateMbps, immersiveMode: (this.immersiveSelect.value as 'ar' | 'vr') || this.getDefaultConfiguration().immersiveMode, app: this.appSelect.value || this.getDefaultConfiguration().app, serverType: this.serverTypeSelect.value || this.getDefaultConfiguration().serverType, proxyUrl: this.proxyUrlInput.value || this.getDefaultConfiguration().proxyUrl, referenceSpaceType: (this.referenceSpaceSelect.value as 'auto' | 'local-floor' | 'local' | 'viewer') || this.getDefaultConfiguration().referenceSpaceType, // Convert cm from UI into meters for config (respect 0; if invalid, use 0) xrOffsetX: (() => { const v = parseFloat(this.xrOffsetXInput.value); return Number.isFinite(v) ? v / 100 : 0; })(), xrOffsetY: (() => { const v = parseFloat(this.xrOffsetYInput.value); return Number.isFinite(v) ? v / 100 : 0; })(), xrOffsetZ: (() => { const v = parseFloat(this.xrOffsetZInput.value); return Number.isFinite(v) ? v / 100 : 0; })(), }; this.currentConfiguration = newConfiguration; // Call the configuration change callback if provided if (this.onConfigurationChange) { this.onConfigurationChange(newConfiguration); } } /** * Gets the current configuration * @returns Current configuration object */ public getConfiguration(): CloudXRConfig { return { ...this.currentConfiguration }; } /** * Sets the start button state * @param disabled - Whether the button should be disabled * @param text - Text to display on the button */ public setStartButtonState(disabled: boolean, text: string): void { if (this.startButton) { this.startButton.disabled = disabled; this.startButton.innerHTML = text; } } /** * Sets up the connect button click handler * @param onConnect - Function to call when connect button is clicked * @param onError - Function to call when an error occurs */ public setupConnectButtonHandler( onConnect: () => Promise, onError: (error: Error) => void ): void { if (this.startButton) { // Remove any existing listener if (this.handleConnectClick) { this.startButton.removeEventListener('click', this.handleConnectClick); } // Create new handler this.handleConnectClick = async () => { // Disable button during XR session this.setStartButtonState(true, 'CONNECT (starting XR session...)'); try { await onConnect(); } catch (error) { this.setStartButtonState(false, 'CONNECT'); onError(error as Error); } }; // Add the new listener this.startButton.addEventListener('click', this.handleConnectClick); } } /** * Shows a status message in the UI with a specific type * @param message - Message to display * @param type - Message type: 'success', 'error', or 'info' */ public showStatus(message: string, type: 'success' | 'error' | 'info'): void { if (this.errorMessageText && this.errorMessageBox) { this.errorMessageText.textContent = message; this.errorMessageBox.className = `error-message-box show ${type}`; } console[type === 'error' ? 'error' : 'info'](message); } /** * Shows an error message in the UI * @param message - Error message to display */ public showError(message: string): void { this.showStatus(message, 'error'); } /** * Hides the error message */ public hideError(): void { if (this.errorMessageBox) { this.errorMessageBox.classList.remove('show'); } } /** * Cleans up event listeners and resources * Should be called when the component unmounts */ public cleanup(): void { // Remove all stored event listeners this.eventListeners.forEach(({ element, event, handler }) => { element.removeEventListener(event, handler); }); this.eventListeners = []; // Remove CONNECT button listener if (this.startButton && this.handleConnectClick) { this.startButton.removeEventListener('click', this.handleConnectClick); this.handleConnectClick = null; } // Clean up certificate acceptance link listeners if (this.certLinkCleanup) { this.certLinkCleanup(); this.certLinkCleanup = null; } } }