sls/public/js/client-call.js

470 lines
15 KiB
JavaScript

/**
* 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 = `<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() {
// 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 = `<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');
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.charAt(0).toUpperCase()}</div>
<div class="video-overlay">
<span>${escapeHtml(peer.peer_name)}</span>
</div>
`;
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[m];
});
}