460 lines
14 KiB
JavaScript
460 lines
14 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);
|
|
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];
|
|
|
|
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);
|
|
|
|
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');
|
|
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');
|
|
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);
|
|
}
|
|
});
|
|
|
|
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');
|
|
|
|
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];
|
|
});
|
|
}
|