sls/resources/js/client-call.js
2026-08-21 19:13:29 +05:30

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)} &bull; ${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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m];
});
}