UNPKG

aethercall

Version:

A scalable WebRTC video calling API built with Node.js and OpenVidu

1,022 lines (877 loc) â€ĸ 41.6 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AetherCall Test - Video Calling</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; background: white; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); overflow: hidden; } .header { background: #2c3e50; color: white; padding: 20px; text-align: center; } .header h1 { margin-bottom: 10px; font-size: 2rem; } .content { padding: 30px; } .card { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; border: 1px solid #e9ecef; } .card h3 { color: #2c3e50; margin-bottom: 15px; font-size: 1.2rem; } .form-group { margin-bottom: 15px; } .form-group label { display: block; margin-bottom: 5px; font-weight: 600; color: #495057; } .form-group input { width: 100%; padding: 12px; border: 2px solid #e9ecef; border-radius: 6px; font-size: 14px; transition: border-color 0.3s; } .form-group input:focus { outline: none; border-color: #667eea; } .button { background: #667eea; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; margin-right: 10px; margin-bottom: 10px; transition: background 0.3s; } .button:hover { background: #5a6fd8; } .button:disabled { background: #6c757d; cursor: not-allowed; } .button.success { background: #28a745; } .button.success:hover { background: #218838; } .button.danger { background: #dc3545; } .button.danger:hover { background: #c82333; } .status { padding: 15px; border-radius: 6px; margin-bottom: 20px; font-weight: 600; } .status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .status.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; } .video-container { margin-top: 20px; } .video-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 20px; } .video-element { background: #000; border-radius: 8px; overflow: hidden; position: relative; aspect-ratio: 16/9; border: 2px solid transparent; transition: border-color 0.3s; } .video-element.speaking { border-color: #28a745; box-shadow: 0 0 15px rgba(40, 167, 69, 0.5); } .video-element.screen-share { border-color: #007bff; } .video-element video { width: 100%; height: 100%; object-fit: cover; } .video-label { position: absolute; bottom: 10px; left: 10px; background: rgba(0,0,0,0.7); color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; } .stream-info { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: white; padding: 5px 8px; border-radius: 4px; font-size: 11px; display: flex; gap: 5px; } .stream-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .stream-indicator.video-on { background: #28a745; } .stream-indicator.video-off { background: #dc3545; } .stream-indicator.audio-on { background: #007bff; } .stream-indicator.audio-off { background: #6c757d; } .controls { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; } .room-info { background: #e8f5e8; border: 1px solid #c3e6cb; border-radius: 6px; padding: 15px; margin-top: 15px; } .room-info h4 { color: #155724; margin-bottom: 10px; } .room-info p { margin: 5px 0; color: #155724; } .participant-count { background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 6px; padding: 10px; margin-top: 15px; text-align: center; font-weight: 600; color: #856404; } .hidden { display: none; } </style> </head> <body> <div class="container"> <div class="header"> <h1>đŸŽĨ AetherCall Video Test</h1> <p>Test the AetherCall API with real video calling functionality</p> </div> <div class="content"> <!-- Status messages --> <div id="status" class="hidden"></div> <!-- API Connection --> <div class="card"> <h3>🔌 API Connection</h3> <div class="form-group"> <label for="apiUrl">AetherCall API URL:</label> <input type="text" id="apiUrl" value="http://localhost:3000" /> </div> <button id="testConnection" class="button">Test Connection</button> <div id="connectionStatus"></div> </div> <!-- Room Management --> <div class="card"> <h3>🏠 Room Management</h3> <p style="color: #6c757d; font-size: 14px; margin-bottom: 15px;"> <strong>Note:</strong> Creating a room requires API authentication. Joining a room does not. </p> <div class="form-group"> <label for="roomCode">Room Code:</label> <input type="text" id="roomCode" value="TEST123" placeholder="Enter room code" /> </div> <div class="form-group"> <label for="displayName">Your Name:</label> <input type="text" id="displayName" value="Test User" placeholder="Enter your name" /> </div> <div class="controls"> <button id="createRoom" class="button">Create New Room</button> <button id="joinRoom" class="button success">Join Room</button> <button id="leaveRoom" class="button danger hidden">Leave Room</button> </div> <div id="roomInfo" class="hidden"></div> </div> <!-- Call Controls --> <div id="callControls" class="card hidden"> <h3>đŸŽ›ī¸ Call Controls</h3> <div class="controls"> <button id="toggleVideo" class="button">📹 Toggle Video</button> <button id="toggleAudio" class="button">🎤 Toggle Audio</button> <button id="shareScreen" class="button">đŸ–Ĩī¸ Share Screen</button> <button id="stopScreenShare" class="button danger hidden">âšī¸ Stop Screen Share</button> <button id="enableAllAudio" class="button">🔊 Enable All Audio</button> <button id="testAudio" class="button">đŸŽĩ Test Audio</button> <button id="startRecording" class="button">🔴 Start Recording</button> <button id="stopRecording" class="button danger hidden">âšī¸ Stop Recording</button> </div> <div id="participantCount" class="participant-count">Participants: 0</div> </div> <!-- Video Container --> <div id="videoContainer" class="video-container hidden"> <h3>đŸ“ē Video Streams</h3> <div id="videoGrid" class="video-grid"> <!-- Videos will be added here --> </div> </div> </div> </div> <!-- OpenVidu Browser SDK --> <script src="https://github.com/OpenVidu/openvidu/releases/download/v2.29.0/openvidu-browser-2.29.0.min.js"></script> <script> // Global variables let session = null; let publisher = null; let screenPublisher = null; // For screen sharing let subscribers = []; let currentRoom = null; let currentSessionId = null; // Store the actual OpenVidu session ID let recordingId = null; let apiToken = null; // DOM elements const elements = { status: document.getElementById('status'), apiUrl: document.getElementById('apiUrl'), testConnection: document.getElementById('testConnection'), connectionStatus: document.getElementById('connectionStatus'), roomCode: document.getElementById('roomCode'), displayName: document.getElementById('displayName'), createRoom: document.getElementById('createRoom'), joinRoom: document.getElementById('joinRoom'), leaveRoom: document.getElementById('leaveRoom'), roomInfo: document.getElementById('roomInfo'), callControls: document.getElementById('callControls'), toggleVideo: document.getElementById('toggleVideo'), toggleAudio: document.getElementById('toggleAudio'), shareScreen: document.getElementById('shareScreen'), stopScreenShare: document.getElementById('stopScreenShare'), enableAllAudio: document.getElementById('enableAllAudio'), testAudio: document.getElementById('testAudio'), startRecording: document.getElementById('startRecording'), stopRecording: document.getElementById('stopRecording'), participantCount: document.getElementById('participantCount'), videoContainer: document.getElementById('videoContainer'), videoGrid: document.getElementById('videoGrid') }; // Utility functions function showStatus(message, type = 'info') { elements.status.className = `status ${type}`; elements.status.textContent = message; elements.status.classList.remove('hidden'); setTimeout(() => { elements.status.classList.add('hidden'); }, 5000); } function updateParticipantCount() { const count = session ? session.streamManagers.length : 0; elements.participantCount.textContent = `Participants: ${count}`; } // API functions async function testApiConnection() { try { showStatus('Testing API connection...', 'info'); // Try health endpoint first, fallback to root if it doesn't exist let response; try { response = await fetch(`${elements.apiUrl.value}/health`); } catch (error) { // If health endpoint fails, try root endpoint response = await fetch(`${elements.apiUrl.value}/`); } if (response.ok) { const data = await response.json(); const version = data.version || 'Unknown'; const uptime = data.uptime ? Math.floor(data.uptime) : 'Unknown'; elements.connectionStatus.innerHTML = ` <div class="status success"> ✅ Connected! Version: ${version}, Uptime: ${uptime}s </div> `; showStatus('API connection successful!', 'success'); return true; } else { throw new Error(`HTTP ${response.status}`); } } catch (error) { elements.connectionStatus.innerHTML = ` <div class="status error"> ❌ Connection failed: ${error.message} </div> `; showStatus(`Connection failed: ${error.message}`, 'error'); return false; } } async function getApiToken() { try { const response = await fetch(`${elements.apiUrl.value}/api/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: 'vanilla-js-test' }) }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } const data = await response.json(); apiToken = data.data.accessToken; return apiToken; } catch (error) { showStatus(`Failed to get API token: ${error.message}`, 'error'); throw error; } } async function createRoom() { try { showStatus('Creating new room...', 'info'); const token = await getApiToken(); const response = await fetch(`${elements.apiUrl.value}/api/auth/room`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ roomName: 'Vanilla JS Test Room', maxParticipants: 10 }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || errorData.message || `HTTP ${response.status}`); } const data = await response.json(); currentRoom = data.data; elements.roomCode.value = data.data.roomCode; elements.roomInfo.innerHTML = ` <div class="room-info"> <h4>✅ Room Created Successfully!</h4> <p><strong>Room Code:</strong> ${data.data.roomCode}</p> <p><strong>Room ID:</strong> ${data.data.roomId}</p> <p><strong>Max Participants:</strong> ${data.data.maxParticipants}</p> <p><strong>Status:</strong> ${data.data.status}</p> </div> `; elements.roomInfo.classList.remove('hidden'); showStatus(`Room created! Code: ${data.data.roomCode}`, 'success'); } catch (error) { showStatus(`Failed to create room: ${error.message}`, 'error'); } } async function joinRoom() { try { if (!elements.roomCode.value || !elements.displayName.value) { showStatus('Please enter room code and display name', 'error'); return; } showStatus('Joining room...', 'info'); const response = await fetch(`${elements.apiUrl.value}/api/connections/join-room`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ roomCode: elements.roomCode.value, userId: `js-user-${Date.now()}`, displayName: elements.displayName.value }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || errorData.message || `HTTP ${response.status}`); } const { data } = await response.json(); // Store the session ID for later use currentSessionId = data.sessionId; console.log('Join room successful:', { sessionId: data.sessionId, connectionId: data.connectionId, roomCode: data.roomCode, displayName: data.displayName }); // Initialize OpenVidu session const OV = new OpenVidu(); session = OV.initSession(); // Event handlers session.on('streamCreated', (event) => { const subscriber = session.subscribe(event.stream, undefined); subscribers.push(subscriber); // Safely parse stream data with error handling let streamData = {}; try { const connectionData = event.stream.connection.data; if (connectionData && connectionData.trim()) { streamData = JSON.parse(connectionData); } } catch (error) { console.warn('Failed to parse connection data:', event.stream.connection.data, error); // Fallback to empty object streamData = {}; } const isScreenShare = event.stream.typeOfVideo === 'SCREEN'; const displayName = streamData.displayName || 'Participant'; const label = isScreenShare ? `${displayName} (Screen)` : displayName; addVideoElement(subscriber, label); updateParticipantCount(); showStatus(`${isScreenShare ? 'Screen share' : 'Participant'} joined`, 'success'); }); session.on('streamDestroyed', (event) => { subscribers = subscribers.filter(sub => sub.stream.streamId !== event.stream.streamId); removeVideoElement(event.stream.streamId); updateParticipantCount(); showStatus(`Participant left`, 'info'); }); // Connect to session await session.connect(data.token, { clientData: JSON.stringify({ userId: data.userId || `js-user-${Date.now()}`, displayName: elements.displayName.value }) }); // Create and publish local stream publisher = await OV.initPublisherAsync(undefined, { audioSource: undefined, videoSource: undefined, publishAudio: true, publishVideo: true, resolution: '640x480', frameRate: 30, insertMode: 'APPEND', mirror: false }); await session.publish(publisher); addVideoElement(publisher, 'You (Local)'); // Update UI elements.createRoom.disabled = true; elements.joinRoom.disabled = true; elements.leaveRoom.classList.remove('hidden'); elements.callControls.classList.remove('hidden'); elements.videoContainer.classList.remove('hidden'); updateParticipantCount(); showStatus(`Successfully joined room: ${elements.roomCode.value}`, 'success'); } catch (error) { showStatus(`Failed to join room: ${error.message}`, 'error'); // Provide helpful hints for common errors if (error.message.includes('Room not found')) { showStatus('Room not found. Make sure the room code is correct and the room has been created.', 'error'); } else if (error.message.includes('inactive')) { showStatus('Room is not active. The room may have ended or not started yet.', 'error'); } console.error('Join room error:', error); } } function addVideoElement(streamManager, label) { const containerElement = document.createElement('div'); containerElement.className = 'video-element'; containerElement.id = `video-${streamManager.stream.streamId}`; // Add class for screen share if (streamManager.stream.typeOfVideo === 'SCREEN') { containerElement.classList.add('screen-share'); } // Create the actual video element that OpenVidu will use const videoElement = document.createElement('video'); videoElement.autoplay = true; videoElement.controls = false; videoElement.muted = false; videoElement.playsInline = true; videoElement.style.width = '100%'; videoElement.style.height = '100%'; videoElement.style.objectFit = 'cover'; const labelElement = document.createElement('div'); labelElement.className = 'video-label'; labelElement.textContent = label; // Add stream info indicators const streamInfo = document.createElement('div'); streamInfo.className = 'stream-info'; const videoIndicator = document.createElement('span'); videoIndicator.className = `stream-indicator ${streamManager.stream.videoActive ? 'video-on' : 'video-off'}`; videoIndicator.title = streamManager.stream.videoActive ? 'Video On' : 'Video Off'; const audioIndicator = document.createElement('span'); audioIndicator.className = `stream-indicator ${streamManager.stream.audioActive ? 'audio-on' : 'audio-off'}`; audioIndicator.title = streamManager.stream.audioActive ? 'Audio On' : 'Audio Off'; streamInfo.appendChild(videoIndicator); streamInfo.appendChild(audioIndicator); // Assemble the container containerElement.appendChild(videoElement); containerElement.appendChild(labelElement); containerElement.appendChild(streamInfo); elements.videoGrid.appendChild(containerElement); // Let OpenVidu attach the stream to our video element streamManager.addVideoElement(videoElement); console.log('Video container created for:', label, { streamId: streamManager.stream.streamId, videoElement: videoElement, hasAudio: streamManager.stream.hasAudio, audioActive: streamManager.stream.audioActive }); // Handle video element events videoElement.addEventListener('loadedmetadata', () => { console.log('Video metadata loaded for:', label); videoElement.play().then(() => { console.log('Video/audio playing successfully for:', label); showStatus(`Media playing for ${label}`, 'success'); }).catch((error) => { console.warn('Autoplay prevented for', label, error); showStatus(`Click on ${label}'s video to enable audio`, 'info'); // Add click handler to enable audio const enableAudio = () => { videoElement.play(); videoElement.removeEventListener('click', enableAudio); showStatus(`Audio enabled for ${label}`, 'success'); }; videoElement.addEventListener('click', enableAudio); }); }); videoElement.addEventListener('playing', () => { console.log('Video started playing for:', label); }); videoElement.addEventListener('error', (error) => { console.error('Video error for', label, error); }); // Listen for stream property changes streamManager.on('streamPropertyChanged', (event) => { console.log('Stream property changed:', event.changedProperty, event.newValue, 'for', label); if (event.changedProperty === 'videoActive') { videoIndicator.className = `stream-indicator ${event.newValue ? 'video-on' : 'video-off'}`; videoIndicator.title = event.newValue ? 'Video On' : 'Video Off'; } else if (event.changedProperty === 'audioActive') { audioIndicator.className = `stream-indicator ${event.newValue ? 'audio-on' : 'audio-off'}`; audioIndicator.title = event.newValue ? 'Audio On' : 'Audio Off'; } }); } function removeVideoElement(streamId) { const videoElement = document.getElementById(`video-${streamId}`); if (videoElement) { videoElement.remove(); } } function leaveRoom() { if (session) { session.disconnect(); session = null; publisher = null; screenPublisher = null; subscribers = []; currentRoom = null; currentSessionId = null; recordingId = null; // Clear video grid elements.videoGrid.innerHTML = ''; // Update UI elements.createRoom.disabled = false; elements.joinRoom.disabled = false; elements.leaveRoom.classList.add('hidden'); elements.callControls.classList.add('hidden'); elements.videoContainer.classList.add('hidden'); elements.stopRecording.classList.add('hidden'); elements.startRecording.classList.remove('hidden'); elements.stopScreenShare.classList.add('hidden'); elements.shareScreen.classList.remove('hidden'); showStatus('Left the room', 'success'); } } function toggleVideo() { if (publisher) { const isVideoActive = publisher.stream.videoActive; publisher.publishVideo(!isVideoActive); elements.toggleVideo.textContent = isVideoActive ? '📹 Turn On Video' : '📹 Turn Off Video'; showStatus(`Video ${isVideoActive ? 'disabled' : 'enabled'}`, 'info'); } } function toggleAudio() { if (publisher) { const isAudioActive = publisher.stream.audioActive; publisher.publishAudio(!isAudioActive); elements.toggleAudio.textContent = isAudioActive ? '🎤 Unmute' : '🎤 Mute'; showStatus(`Audio ${isAudioActive ? 'muted' : 'unmuted'}`, 'info'); } } async function shareScreen() { try { if (!session) { showStatus('No active session to share screen', 'error'); return; } showStatus('Starting screen share...', 'info'); // Option 1: Replace camera with screen share // Unpublish camera stream first if (publisher) { await session.unpublish(publisher); removeVideoElement(publisher.stream.streamId); console.log('Unpublished camera stream for screen share'); } const OV = new OpenVidu(); screenPublisher = await OV.initPublisherAsync(undefined, { videoSource: 'screen', publishAudio: true, // Include system audio if available publishVideo: true, mirror: false }); await session.publish(screenPublisher); addVideoElement(screenPublisher, 'You (Screen Share)'); // Update UI elements.shareScreen.classList.add('hidden'); elements.stopScreenShare.classList.remove('hidden'); elements.toggleVideo.disabled = true; // Disable camera toggle during screen share showStatus('Screen sharing started! (Camera paused)', 'success'); // Handle screen share end (when user stops via browser UI) screenPublisher.on('accessDenied', () => { showStatus('Screen share access denied', 'error'); stopScreenShare(); }); screenPublisher.on('streamDestroyed', () => { console.log('Screen share stream destroyed'); stopScreenShare(); }); } catch (error) { showStatus(`Failed to start screen share: ${error.message}`, 'error'); console.error('Screen share error:', error); // If screen share fails, restore camera if (publisher && !session.streamManagers.includes(publisher)) { try { await session.publish(publisher); addVideoElement(publisher, 'You (Local)'); console.log('Restored camera stream after screen share failure'); } catch (restoreError) { console.error('Failed to restore camera:', restoreError); } } } } async function stopScreenShare() { if (screenPublisher) { try { await session.unpublish(screenPublisher); removeVideoElement(screenPublisher.stream.streamId); screenPublisher = null; // Restore camera stream if (publisher) { await session.publish(publisher); addVideoElement(publisher, 'You (Local)'); console.log('Restored camera stream after stopping screen share'); } // Update UI elements.shareScreen.classList.remove('hidden'); elements.stopScreenShare.classList.add('hidden'); elements.toggleVideo.disabled = false; // Re-enable camera toggle showStatus('Screen sharing stopped, camera restored', 'info'); } catch (error) { console.error('Error stopping screen share:', error); showStatus('Error stopping screen share', 'error'); } } } function enableAllAudio() { // Enable audio for all video elements const videoElements = document.querySelectorAll('video'); let enabledCount = 0; console.log('Found video elements:', videoElements.length); videoElements.forEach((video, index) => { console.log(`Video ${index}:`, { src: video.src, srcObject: video.srcObject, muted: video.muted, volume: video.volume, paused: video.paused, readyState: video.readyState }); video.muted = false; video.volume = 1.0; // Try to play if paused or not playing if (video.paused || video.readyState === 0) { video.play().then(() => { enabledCount++; console.log(`Audio enabled for video ${index}`); }).catch(error => { console.warn(`Could not enable audio for video ${index}:`, error); }); } else { enabledCount++; console.log(`Video ${index} already playing`); } }); showStatus(`Audio enabled for ${enabledCount}/${videoElements.length} video streams`, 'success'); console.log('Audio status check:', { totalVideos: videoElements.length, enabledCount: enabledCount, subscribers: subscribers.length, publisher: publisher ? 'exists' : 'none' }); } function testAudio() { // Create a test audio beep const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 800; gainNode.gain.setValueAtTime(0.1, audioContext.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.5); showStatus('Audio test beep played - if you hear it, your audio is working', 'info'); // Also log current audio states console.log('Audio debug info:', { publisher: publisher ? { audioActive: publisher.stream.audioActive, hasAudio: publisher.stream.hasAudio } : 'none', subscribers: subscribers.map(sub => ({ audioActive: sub.stream.audioActive, hasAudio: sub.stream.hasAudio, connectionData: sub.stream.connection.data })), videoElements: Array.from(document.querySelectorAll('video')).map(v => ({ muted: v.muted, volume: v.volume, paused: v.paused })) }); } async function startRecording() { try { if (!session || !currentSessionId) { showStatus('No active session to record', 'error'); return; } showStatus('Starting recording...', 'info'); const token = await getApiToken(); const response = await fetch(`${elements.apiUrl.value}/api/recordings/start`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: currentSessionId, name: `vanilla-js-recording-${Date.now()}`, outputMode: 'COMPOSED', recordingLayout: 'BEST_FIT' }) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || errorData.message || `HTTP ${response.status}`); } const data = await response.json(); recordingId = data.data.recordingId; elements.startRecording.classList.add('hidden'); elements.stopRecording.classList.remove('hidden'); showStatus(`Recording started! ID: ${recordingId}`, 'success'); } catch (error) { showStatus(`Failed to start recording: ${error.message}`, 'error'); } } async function stopRecording() { try { if (!recordingId) { showStatus('No active recording to stop', 'error'); return; } showStatus('Stopping recording...', 'info'); const token = await getApiToken(); const response = await fetch(`${elements.apiUrl.value}/api/recordings/stop/${recordingId}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || errorData.message || `HTTP ${response.status}`); } const data = await response.json(); elements.startRecording.classList.remove('hidden'); elements.stopRecording.classList.add('hidden'); recordingId = null; showStatus(`Recording stopped! Duration: ${data.data.duration}s`, 'success'); } catch (error) { showStatus(`Failed to stop recording: ${error.message}`, 'error'); } } // Event listeners elements.testConnection.addEventListener('click', testApiConnection); elements.createRoom.addEventListener('click', createRoom); elements.joinRoom.addEventListener('click', joinRoom); elements.leaveRoom.addEventListener('click', leaveRoom); elements.toggleVideo.addEventListener('click', toggleVideo); elements.toggleAudio.addEventListener('click', toggleAudio); elements.shareScreen.addEventListener('click', shareScreen); elements.stopScreenShare.addEventListener('click', stopScreenShare); elements.enableAllAudio.addEventListener('click', enableAllAudio); elements.testAudio.addEventListener('click', testAudio); elements.startRecording.addEventListener('click', startRecording); elements.stopRecording.addEventListener('click', stopRecording); // Initialize document.addEventListener('DOMContentLoaded', () => { testApiConnection(); }); </script> </body> </html>