723 lines
24 KiB
JavaScript
723 lines
24 KiB
JavaScript
/**
|
|
* SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine
|
|
* Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis,
|
|
* In-call chat, and Full Call Recording with automated server upload.
|
|
*/
|
|
|
|
let localStream = null;
|
|
let screenStream = null;
|
|
let peerConnections = {};
|
|
let isAudioMuted = false;
|
|
let isVideoMuted = false;
|
|
let isScreenSharing = false;
|
|
|
|
// Call Recording State
|
|
let mediaRecorder = null;
|
|
let recordedChunks = [];
|
|
let isRecording = false;
|
|
let recordingStartTime = null;
|
|
let recordingTimerInterval = null;
|
|
let recordingStream = null;
|
|
let recordedVideoBlob = null;
|
|
let recordedVideoPath = null;
|
|
|
|
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.toggleCallRecording = toggleCallRecording;
|
|
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);
|
|
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);
|
|
btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone';
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function toggleScreenShare() {
|
|
const btn = document.getElementById('btnScreenShare');
|
|
if (!isScreenSharing) {
|
|
try {
|
|
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
|
|
const screenTrack = screenStream.getVideoTracks()[0];
|
|
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* =========================================================================
|
|
* Call Recording Engine
|
|
* =========================================================================
|
|
*/
|
|
async function toggleCallRecording() {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
} else {
|
|
await startRecording();
|
|
}
|
|
}
|
|
|
|
async function startRecording() {
|
|
try {
|
|
recordedChunks = [];
|
|
let combinedStream = null;
|
|
|
|
// Try getting screen/tab capture with system audio
|
|
try {
|
|
if (navigator.mediaDevices.getDisplayMedia) {
|
|
if (screenStream && screenStream.active) {
|
|
combinedStream = screenStream.clone();
|
|
} else {
|
|
const screenCapture = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { displaySurface: 'browser' },
|
|
audio: true
|
|
});
|
|
combinedStream = screenCapture;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.info('Display capture not selected, recording camera stream directly:', e);
|
|
}
|
|
|
|
if (!combinedStream && localStream) {
|
|
combinedStream = localStream.clone();
|
|
}
|
|
|
|
if (!combinedStream) {
|
|
alert('No camera or display stream available to record.');
|
|
return;
|
|
}
|
|
|
|
// Mix local microphone audio if available
|
|
if (localStream && localStream.getAudioTracks().length > 0 && window.AudioContext) {
|
|
try {
|
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const dest = audioCtx.createMediaStreamDestination();
|
|
|
|
// Local mic
|
|
const micSource = audioCtx.createMediaStreamSource(localStream);
|
|
micSource.connect(dest);
|
|
|
|
// Screen audio if captured
|
|
if (combinedStream.getAudioTracks().length > 0) {
|
|
const screenAudioSource = audioCtx.createMediaStreamSource(new MediaStream([combinedStream.getAudioTracks()[0]]));
|
|
screenAudioSource.connect(dest);
|
|
}
|
|
|
|
const mixedAudioTrack = dest.stream.getAudioTracks()[0];
|
|
if (mixedAudioTrack) {
|
|
combinedStream.getAudioTracks().forEach(t => combinedStream.removeTrack(t));
|
|
combinedStream.addTrack(mixedAudioTrack);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Audio mixing fallback:', err);
|
|
if (combinedStream.getAudioTracks().length === 0 && localStream.getAudioTracks().length > 0) {
|
|
combinedStream.addTrack(localStream.getAudioTracks()[0].clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
recordingStream = combinedStream;
|
|
|
|
// Determine supported mimeTypes
|
|
let options = { mimeType: 'video/webm;codecs=vp9,opus' };
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
|
}
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
options = { mimeType: 'video/webm' };
|
|
}
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
options = { mimeType: 'video/mp4' };
|
|
}
|
|
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
|
options = {};
|
|
}
|
|
|
|
mediaRecorder = new MediaRecorder(recordingStream, options);
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data && event.data.size > 0) {
|
|
recordedChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
clearInterval(recordingTimerInterval);
|
|
isRecording = false;
|
|
updateRecordingUI(false);
|
|
|
|
if (recordingStream) {
|
|
recordingStream.getTracks().forEach(t => t.stop());
|
|
recordingStream = null;
|
|
}
|
|
|
|
if (recordedChunks.length > 0) {
|
|
const mimeType = mediaRecorder.mimeType || 'video/webm';
|
|
recordedVideoBlob = new Blob(recordedChunks, { type: mimeType });
|
|
await uploadRecordingBlob(recordedVideoBlob);
|
|
}
|
|
};
|
|
|
|
recordingStream.getVideoTracks().forEach(track => {
|
|
track.onended = () => {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
}
|
|
};
|
|
});
|
|
|
|
mediaRecorder.start(1000);
|
|
isRecording = true;
|
|
recordingStartTime = Date.now();
|
|
startRecordingTimer();
|
|
updateRecordingUI(true);
|
|
showToastNotification('🔴 Call recording started');
|
|
} catch (err) {
|
|
console.error('Error starting recording:', err);
|
|
alert('Could not start call recording: ' + (err.message || err));
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
mediaRecorder.stop();
|
|
showToastNotification('⏹️ Stopping recording & saving...');
|
|
}
|
|
}
|
|
|
|
function startRecordingTimer() {
|
|
const timerEl = document.getElementById('recTimer');
|
|
recordingTimerInterval = setInterval(() => {
|
|
const elapsedSec = Math.floor((Date.now() - recordingStartTime) / 1000);
|
|
const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0');
|
|
const secs = String(elapsedSec % 60).padStart(2, '0');
|
|
if (timerEl) timerEl.innerText = `REC ${mins}:${secs}`;
|
|
}, 1000);
|
|
}
|
|
|
|
function updateRecordingUI(recording) {
|
|
const badge = document.getElementById('recordingStatusBadge');
|
|
const recIconStart = document.getElementById('recIconStart');
|
|
const recIconStop = document.getElementById('recIconStop');
|
|
const btn = document.getElementById('btnToggleRecord');
|
|
|
|
if (recording) {
|
|
if (badge) badge.style.display = 'inline-flex';
|
|
if (recIconStart) recIconStart.style.display = 'none';
|
|
if (recIconStop) recIconStop.style.display = 'inline';
|
|
if (btn) {
|
|
btn.classList.add('active-rec');
|
|
btn.title = 'Stop Call Recording';
|
|
}
|
|
} else {
|
|
if (badge) badge.style.display = 'none';
|
|
if (recIconStart) recIconStart.style.display = 'inline';
|
|
if (recIconStop) recIconStop.style.display = 'none';
|
|
if (btn) {
|
|
btn.classList.remove('active-rec');
|
|
btn.title = 'Start Call Recording';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function uploadRecordingBlob(blob) {
|
|
if (!blob || blob.size === 0) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
const uploadUrl = config.uploadRecordingUrl;
|
|
|
|
if (!uploadUrl) return;
|
|
|
|
const formData = new FormData();
|
|
const ext = blob.type.includes('mp4') ? 'mp4' : 'webm';
|
|
formData.append('video', blob, `client_call_${config.meetingCode || 'rec'}_${Date.now()}.${ext}`);
|
|
|
|
try {
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'Accept': 'application/json'
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data && data.success) {
|
|
recordedVideoPath = data.path;
|
|
showToastNotification('✅ Video recording saved & uploaded successfully!');
|
|
showDownloadPrompt(blob, ext);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to upload recording:', err);
|
|
showToastNotification('⚠️ Cloud upload delayed. You can download the recording locally.');
|
|
showDownloadPrompt(blob, ext);
|
|
}
|
|
}
|
|
|
|
function showDownloadPrompt(blob, ext) {
|
|
const modal = document.getElementById('recordingSavedModal');
|
|
if (modal) {
|
|
const downloadBtn = document.getElementById('btnDownloadSavedRec');
|
|
if (downloadBtn) {
|
|
const url = URL.createObjectURL(blob);
|
|
downloadBtn.href = url;
|
|
downloadBtn.download = `Meeting-${config.meetingCode || 'recording'}-${Date.now()}.${ext}`;
|
|
}
|
|
modal.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
function showToastNotification(msg) {
|
|
const toast = document.getElementById('toastNotification');
|
|
if (toast) {
|
|
toast.innerText = msg;
|
|
toast.style.display = 'block';
|
|
setTimeout(() => { toast.style.display = 'none'; }, 3500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* =========================================================================
|
|
* 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);
|
|
|
|
checkEmotionOnText(text);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
speechRecognizer.onerror = (e) => {
|
|
console.warn('Speech Recognition error:', e.error);
|
|
};
|
|
|
|
speechRecognizer.onend = () => {
|
|
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 = `<span class="transcript-speaker">${escapeHtml(line.speaker)} (${line.time}):</span> <span class="transcript-text">${escapeHtml(line.text)}</span>`;
|
|
feed.appendChild(div);
|
|
feed.scrollTop = feed.scrollHeight;
|
|
}
|
|
|
|
/**
|
|
* Live Emotion & Expression Monitoring
|
|
*/
|
|
function startEmotionMonitoring() {
|
|
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');
|
|
if (!input) return;
|
|
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();
|
|
|
|
if (data.chat_messages) {
|
|
renderChat(data.chat_messages);
|
|
}
|
|
|
|
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 = `<div class="chat-sender">${escapeHtml(m.sender)} • ${m.time || ''}</div><div>${escapeHtml(m.text)}</div>`;
|
|
feed.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function handleActivePeers(peers) {
|
|
const grid = document.getElementById('videoGrid');
|
|
if (!grid) return;
|
|
|
|
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 = `
|
|
<div class="video-avatar-placeholder">${(peer.peer_name || 'U').charAt(0).toUpperCase()}</div>
|
|
<div class="video-overlay">
|
|
<span>${escapeHtml(peer.peer_name)}</span>
|
|
</div>
|
|
`;
|
|
grid.appendChild(tile);
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// Stop recording if running and allow a moment to complete
|
|
if (isRecording && mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
stopRecording();
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
}
|
|
|
|
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');
|
|
|
|
const btn = document.querySelector('.btn-end-call');
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerText = 'Generating AI MoM & Saving Notes...';
|
|
}
|
|
|
|
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,
|
|
recording_path: recordedVideoPath
|
|
})
|
|
});
|
|
|
|
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 completed. Redirecting to Client Hub...');
|
|
window.location.href = config.redirectUrl;
|
|
}
|
|
}
|
|
|
|
function leaveCall() {
|
|
if (confirm('Leave this meeting?')) {
|
|
if (isRecording) {
|
|
stopRecording();
|
|
}
|
|
window.location.href = config.redirectUrl || '/';
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/[&<>"']/g, function(m) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m];
|
|
});
|
|
}
|