/** * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine */ let localStream = null; let screenStream = null; let peerConnections = {}; let isAudioMuted = false; let isVideoMuted = false; let isScreenSharing = false; let accumulatedTranscript = []; let speechRecognizer = null; let emotionTimeline = []; let callStartTime = Date.now(); let peerId = 'peer_' + Math.random().toString(36).substring(2, 9); const config = window.CLIENT_CALL_CONFIG || {}; // Standard ICE servers const iceServers = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun.cloudflare.com:3478' } ]; document.addEventListener('DOMContentLoaded', async () => { initTimer(); await initMedia(); initSpeechRecognition(); startHeartbeatLoop(); startEmotionMonitoring(); window.toggleAudio = toggleAudio; window.toggleVideo = toggleVideo; window.toggleScreenShare = toggleScreenShare; window.sendChatMessage = sendChatMessage; window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; window.leaveCall = leaveCall; }); /** * Timer */ function initTimer() { const timerEl = document.getElementById('callTimer'); setInterval(() => { const elapsedSec = Math.floor((Date.now() - callStartTime) / 1000); const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0'); const secs = String(elapsedSec % 60).padStart(2, '0'); if (timerEl) timerEl.innerText = `${mins}:${secs}`; }, 1000); } /** * Initialize Local Camera & Mic Stream */ async function initMedia() { try { localStream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true }); const localVideo = document.getElementById('localVideo'); if (localVideo) { localVideo.srcObject = localStream; } } catch (err) { console.warn('Camera/Mic access denied or unavailable:', err); // Fallback: audio only or dummy stream try { localStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { console.warn('Audio also unavailable:', e); } } } /** * Audio / Video Toggles */ function toggleAudio() { if (!localStream) return; const audioTrack = localStream.getAudioTracks()[0]; if (audioTrack) { isAudioMuted = !isAudioMuted; audioTrack.enabled = !isAudioMuted; const btn = document.getElementById('btnToggleMic'); if (btn) { btn.classList.toggle('active-off', isAudioMuted); } } } function toggleVideo() { if (!localStream) return; const videoTrack = localStream.getVideoTracks()[0]; if (videoTrack) { isVideoMuted = !isVideoMuted; videoTrack.enabled = !isVideoMuted; const btn = document.getElementById('btnToggleVideo'); if (btn) { btn.classList.toggle('active-off', isVideoMuted); } } } async function toggleScreenShare() { const btn = document.getElementById('btnScreenShare'); if (!isScreenSharing) { try { screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack = screenStream.getVideoTracks()[0]; // Replace video track for peers for (let id in peerConnections) { const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); if (sender) sender.replaceTrack(screenTrack); } const localVideo = document.getElementById('localVideo'); if (localVideo) localVideo.srcObject = screenStream; screenTrack.onended = () => { stopScreenShare(); }; isScreenSharing = true; if (btn) btn.classList.add('active-on'); } catch (e) { console.warn('Screen share cancelled:', e); } } else { stopScreenShare(); } } function stopScreenShare() { if (screenStream) { screenStream.getTracks().forEach(t => t.stop()); screenStream = null; } if (localStream) { const videoTrack = localStream.getVideoTracks()[0]; for (let id in peerConnections) { const sender = peerConnections[id].getSenders().find(s => s.track && s.track.kind === 'video'); if (sender && videoTrack) sender.replaceTrack(videoTrack); } const localVideo = document.getElementById('localVideo'); if (localVideo) localVideo.srcObject = localStream; } isScreenSharing = false; const btn = document.getElementById('btnScreenShare'); if (btn) btn.classList.remove('active-on'); } /** * Web Speech Recognition for Live Transcription & AI Note Taking */ function initSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const feed = document.getElementById('transcriptFeed'); const statusEl = document.getElementById('sttStatus'); if (!SpeechRecognition) { if (statusEl) statusEl.innerText = '● Speech API (Manual)'; return; } try { speechRecognizer = new SpeechRecognition(); speechRecognizer.continuous = true; speechRecognizer.interimResults = true; speechRecognizer.lang = 'en-US'; speechRecognizer.onresult = (event) => { for (let i = event.resultIndex; i < event.results.length; ++i) { if (event.results[i].isFinal) { const text = event.results[i][0].transcript.trim(); if (text.length > 0) { const line = { speaker: config.participantName || 'Speaker', text: text, time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }; accumulatedTranscript.push(line); appendTranscriptLine(line); // Check emotion on spoken text checkEmotionOnText(text); } } } }; speechRecognizer.onerror = (e) => { console.warn('Speech Recognition error:', e.error); }; speechRecognizer.onend = () => { // Auto restart recognition try { speechRecognizer.start(); } catch (err) {} }; speechRecognizer.start(); } catch (e) { console.warn('Could not initialize Speech Recognition:', e); } } function appendTranscriptLine(line) { const feed = document.getElementById('transcriptFeed'); if (!feed) return; const div = document.createElement('div'); div.className = 'transcript-line'; div.innerHTML = `${escapeHtml(line.speaker)} (${line.time}): ${escapeHtml(line.text)}`; feed.appendChild(div); feed.scrollTop = feed.scrollHeight; } /** * Live Emotion & Expression Monitoring */ function startEmotionMonitoring() { // Check face emotion snapshot every 10 seconds setInterval(() => { captureAndAnalyzeFrame(); }, 10000); } function captureAndAnalyzeFrame() { const video = document.getElementById('localVideo'); const canvas = document.getElementById('emotionCanvas'); if (!video || !canvas || !video.videoWidth) return; canvas.width = 320; canvas.height = 240; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const frameBase64 = canvas.toDataURL('image/jpeg', 0.6); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); fetch(config.detectEmotionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ image_frame: frameBase64, speech_text: accumulatedTranscript.slice(-3).map(l => l.text).join(' ') }) }) .then(res => res.json()) .then(data => { if (data && data.emotion) { updateEmotionBadge(data.emotion, data.label); emotionTimeline.push({ emotion: data.emotion, time: Date.now() }); } }) .catch(() => {}); } function checkEmotionOnText(text) { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); fetch(config.detectEmotionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ speech_text: text }) }) .then(res => res.json()) .then(data => { if (data && data.emotion) { updateEmotionBadge(data.emotion, data.label); emotionTimeline.push({ emotion: data.emotion, time: Date.now() }); } }) .catch(() => {}); } function updateEmotionBadge(emotion, label) { const badge = document.getElementById('liveEmotionBadge'); const emojiEl = document.getElementById('emotionEmoji'); const textEl = document.getElementById('emotionText'); if (!badge || !emojiEl || !textEl) return; badge.className = 'emotion-live-indicator'; if (emotion === 'happy') { badge.classList.add('emotion-badge-happy'); emojiEl.innerText = '😊'; textEl.innerText = label || 'Client Mood: Happy / Satisfied'; } else if (emotion === 'angry') { badge.classList.add('emotion-badge-angry'); emojiEl.innerText = '😡'; textEl.innerText = label || 'Client Mood: Frustrated / Angry'; } else if (emotion === 'sad') { badge.classList.add('emotion-badge-sad'); emojiEl.innerText = '😟'; textEl.innerText = label || 'Client Mood: Concerned / Sad'; } else { badge.classList.add('emotion-badge-neutral'); emojiEl.innerText = '😐'; textEl.innerText = label || 'Client Mood: Neutral / Attentive'; } } /** * WebRTC Signaling & Chat Loop */ let outgoingChatMessage = null; function sendChatMessage() { const input = document.getElementById('chatInput'); const text = input.value.trim(); if (!text) return; outgoingChatMessage = { text: text }; input.value = ''; } function startHeartbeatLoop() { setInterval(async () => { const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); try { const res = await fetch(config.heartbeatUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ peer_id: peerId, peer_name: config.participantName, chat_message: outgoingChatMessage }) }); outgoingChatMessage = null; const data = await res.json(); // Update chat feed if (data.chat_messages) { renderChat(data.chat_messages); } // Manage remote peers if (data.active_peers) { handleActivePeers(data.active_peers); } } catch (e) { console.debug('Heartbeat error:', e); } }, 2500); } function renderChat(messages) { const feed = document.getElementById('chatFeed'); if (!feed) return; feed.innerHTML = ''; messages.forEach(m => { const div = document.createElement('div'); div.className = 'chat-msg'; div.innerHTML = `
${escapeHtml(m.sender)} • ${m.time || ''}
${escapeHtml(m.text)}
`; feed.appendChild(div); }); } function handleActivePeers(peers) { const grid = document.getElementById('videoGrid'); peers.forEach(peer => { if (peer.peer_id === peerId) return; let tile = document.getElementById('tile_' + peer.peer_id); if (!tile) { tile = document.createElement('div'); tile.id = 'tile_' + peer.peer_id; tile.className = 'video-tile'; tile.innerHTML = `
${peer.peer_name.charAt(0).toUpperCase()}
${escapeHtml(peer.peer_name)}
`; grid.appendChild(tile); } }); // Remove left peers const activeIds = peers.map(p => 'tile_' + p.peer_id); document.querySelectorAll('.video-tile').forEach(tile => { if (tile.id !== 'localVideoTile' && !activeIds.includes(tile.id)) { tile.remove(); } }); } /** * End Call & MoM Generation */ async function endMeetingAndGenerateMoM() { if (!confirm('Are you sure you want to end this client call and generate AI Minutes of Meeting (MoM)?')) { return; } const liveNotes = document.getElementById('liveNotesInput')?.value || ''; const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); // Show processing indicator const btn = document.querySelector('.btn-end-call'); if (btn) { btn.disabled = true; btn.innerText = 'Generating AI MoM & Dispatching Emails...'; } try { const res = await fetch(config.endCallUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ transcript: transcriptText, live_notes: liveNotes, emotion_timeline: emotionTimeline }) }); const data = await res.json(); if (data.redirect_url) { window.location.href = data.redirect_url; } else { window.location.href = config.redirectUrl; } } catch (e) { alert('Meeting ended. Redirecting to Client Hub...'); window.location.href = config.redirectUrl; } } function leaveCall() { if (confirm('Leave this meeting?')) { window.location.href = config.redirectUrl || '/'; } } function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>"']/g, function(m) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; }); }