FocusFlow / frontend /script.js
immortalindeed's picture
Fix: Properly hide video overlay during active sessions
f9210d7
// script.js
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const btnDashboard = document.getElementById('btn-dashboard');
const btnLive = document.getElementById('btn-live');
const btnMeeting = document.getElementById('btn-meeting');
const btnHistory = document.getElementById('btn-history');
const viewDashboard = document.getElementById('view-dashboard');
const viewLive = document.getElementById('view-live');
const viewMeeting = document.getElementById('view-meeting');
const viewHistory = document.getElementById('view-history');
const pageTitle = document.getElementById('page-title');
const pageSubtitle = document.getElementById('page-subtitle');
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
const statusIndicator = document.getElementById('status-indicator');
const recordingTime = document.getElementById('recording-time');
const videoOverlay = document.getElementById('video-overlay');
// Video elements
const video = document.getElementById('webcam');
const meetingVideo = document.getElementById('meeting-video'); // Meeting specific video
const canvas = document.createElement('canvas'); // For grabbing frames
const ctx = canvas.getContext('2d');
// UI Panels
const meetingSetup = document.getElementById('meeting-setup');
const meetingActiveVideo = document.getElementById('meeting-active-video');
// Metrics Elements
const engagementScore = document.getElementById('engagement-score');
const engagementBar = document.getElementById('engagement-bar');
const statusText = document.getElementById('status-text');
const yawnCount = document.getElementById('yawn-count');
const drowsyCount = document.getElementById('drowsy-count');
const earValue = document.getElementById('ear-value');
const emotionLabel = document.getElementById('emotion-label');
const gazeScore = document.getElementById('gaze-score');
const stabilityScore = document.getElementById('stability-score');
const qualityScore = document.getElementById('quality-score');
const attentionScore = document.getElementById('attention-score');
const alertBanner = document.getElementById('alert-banner');
const historyList = document.getElementById('history-list');
const btnShareScreen = document.getElementById('btn-share-screen');
const btnStartMeeting = document.getElementById('btn-start-meeting');
// UI Navigation
function switchView(viewName) {
// Reset all
btnDashboard.classList.remove('active');
btnLive.classList.remove('active');
btnMeeting?.classList.remove('active');
btnHistory.classList.remove('active');
viewDashboard.classList.add('hidden');
viewLive.classList.add('hidden');
viewMeeting?.classList.add('hidden');
viewHistory.classList.add('hidden');
// Show target
if (viewName === 'dashboard') {
btnDashboard.classList.add('active');
viewDashboard.classList.remove('hidden');
pageTitle.textContent = "Dashboard";
pageSubtitle.textContent = "Overview and Quick Start";
updateDashboardStats();
} else if (viewName === 'live') {
btnLive.classList.add('active');
viewLive.classList.remove('hidden');
pageTitle.textContent = "Live Analysis";
pageSubtitle.textContent = "Real-Time Engagement Tracking";
sessionMode = "individual";
} else if (viewName === 'meeting') {
btnMeeting?.classList.add('active');
viewMeeting?.classList.remove('hidden');
pageTitle.textContent = "Meeting Intel";
pageSubtitle.textContent = "Professional Group Analytics";
sessionMode = "meeting";
} else if (viewName === 'history') {
btnHistory.classList.add('active');
viewHistory.classList.remove('hidden');
pageTitle.textContent = "History";
pageSubtitle.textContent = "Review Your Progress";
loadHistory();
}
}
async function updateDashboardStats() {
try {
const resp = await fetch('/api/stats/dashboard');
const data = await resp.json();
document.getElementById('dash-total-sessions').textContent = data.total_sessions || 0;
document.getElementById('dash-avg-focus').textContent = (data.avg_engagement || 0) + '%';
document.getElementById('dash-focus-time').textContent = (data.focus_minutes || 0) + 'm';
// If avg is high, use success color
const avgElem = document.getElementById('dash-avg-focus');
if (data.avg_engagement >= 80) avgElem.style.color = 'var(--status-success)';
else if (data.avg_engagement < 50) avgElem.style.color = 'var(--status-danger)';
else avgElem.style.color = 'var(--text-primary)';
} catch (err) {
console.error("Error fetching dashboard stats:", err);
}
}
btnDashboard.addEventListener('click', () => switchView('dashboard'));
btnLive.addEventListener('click', () => switchView('live'));
btnMeeting?.addEventListener('click', () => switchView('meeting'));
btnHistory.addEventListener('click', () => switchView('history'));
// WebRTC & WebSockets
let stream = null;
let ws = null;
let isRecording = false;
let timerInterval = null;
let startTime = null;
let frameInterval = null;
let backendReady = true; // Flow control
let sessionMode = "individual";
let captureSource = "camera"; // "camera" or "screen"
// Format timer
function formatTime(seconds) {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = Math.floor(seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function updateTimer() {
if (!startTime) return;
const now = Date.now();
const diff = (now - startTime) / 1000;
recordingTime.textContent = formatTime(diff);
}
async function startCamera() {
try {
captureSource = "camera";
stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, frameRate: 10 }
});
const currentVideo = sessionMode === "meeting" ? meetingVideo : video;
currentVideo.srcObject = stream;
videoOverlay.classList.add('hidden');
if (sessionMode === "meeting") {
meetingSetup.classList.add('hidden');
meetingActiveVideo.classList.remove('hidden');
}
// Add a small debug overlay on top of video container
let debugOverlay = document.getElementById('camera-debug-info');
if (!debugOverlay) {
debugOverlay = document.createElement('div');
debugOverlay.id = "camera-debug-info";
debugOverlay.style = "position:absolute; top:10px; left:10px; background:rgba(0,0,0,0.5); color:white; padding:5px 10px; border-radius:4px; font-family:monospace; font-size:12px; z-index:100; pointer-events:none;";
const container = sessionMode === "meeting" ? meetingActiveVideo : document.querySelector('.video-container');
container.appendChild(debugOverlay);
}
debugOverlay.innerHTML = "Initializing Camera...";
return true;
} catch (err) {
console.error("Error accessing webcam: ", err);
alert("Could not access webcam. Please ensure permissions are granted.");
return false;
}
}
async function startScreenShare() {
try {
captureSource = "screen";
stream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 5 }
});
const currentVideo = sessionMode === "meeting" ? meetingVideo : video;
currentVideo.srcObject = stream;
videoOverlay.classList.add('hidden');
if (sessionMode === "meeting") {
meetingSetup.classList.add('hidden');
meetingActiveVideo.classList.remove('hidden');
}
let debugOverlay = document.getElementById('camera-debug-info');
if (!debugOverlay) {
debugOverlay = document.createElement('div');
debugOverlay.id = "camera-debug-info";
debugOverlay.style = "position:absolute; top:10px; left:10px; background:rgba(0,0,0,0.5); color:white; padding:5px 10px; border-radius:4px; font-family:monospace; font-size:12px; z-index:100; pointer-events:none;";
const container = sessionMode === "meeting" ? meetingActiveVideo : document.querySelector('.video-container');
container.appendChild(debugOverlay);
}
debugOverlay.innerHTML = "Screen Sharing Active";
// If the user stops sharing via browser UI
stream.getVideoTracks()[0].onended = () => {
if (isRecording) stopSession();
};
return true;
} catch (err) {
console.error("Error starting screen share:", err);
return false;
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
video.srcObject = null;
if (meetingVideo) meetingVideo.srcObject = null;
}
videoOverlay.classList.remove('hidden');
meetingSetup?.classList.remove('hidden');
meetingActiveVideo?.classList.add('hidden');
document.getElementById('camera-debug-info')?.remove();
}
function connectWebSocket() {
// Dynamically detect protocol for secure environments (Hugging Face)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/stream`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("WebSocket connected");
const title = sessionMode === "meeting" ? (document.getElementById('meeting-title')?.value || "Group Meeting") : "Live Analysis Session";
ws.send(JSON.stringify({
action: "start_session",
mode: sessionMode,
title: title
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "info") {
console.log("Server Info:", data.message);
backendReady = true;
return;
}
if (data.type === "metrics") {
updateUI(data);
backendReady = true;
}
};
ws.onclose = (event) => {
console.log("WebSocket disconnected", event.code, event.reason);
if (isRecording) {
if (event.code !== 1000) {
alertBanner.classList.remove('hidden');
alertBanner.querySelector('p').textContent = `Connection lost: ${event.reason || 'Server error'}. Please refresh.`;
}
stopSession();
}
};
ws.onerror = (err) => {
console.error("WebSocket Error:", err);
alertBanner.classList.remove('hidden');
alertBanner.querySelector('p').textContent = "WebSocket Connection Error. Check if server is running.";
backendReady = true;
};
}
function sendFrame() {
if (!isRecording || !ws || ws.readyState !== WebSocket.OPEN) return;
if (!backendReady) return; // Wait for backend to finish previous frame
const currentVideo = sessionMode === "meeting" ? meetingVideo : video;
if (currentVideo && currentVideo.readyState === currentVideo.HAVE_ENOUGH_DATA) {
canvas.width = currentVideo.videoWidth;
canvas.height = currentVideo.videoHeight;
ctx.drawImage(currentVideo, 0, 0, canvas.width, canvas.height);
// Compress to JPEG for faster transmission
const base64Data = canvas.toDataURL('image/jpeg', 0.5);
backendReady = false; // Block until we get a response
ws.send(JSON.stringify({ frame: base64Data }));
}
}
// UI Updates based on WebSockets
function updateUI(data) {
if (sessionMode === "meeting") {
const avgScore = Math.round(data.score || 0);
const meetingAvgScore = document.getElementById('meeting-avg-score');
const pCount = document.getElementById('participant-count');
const mWarnings = document.getElementById('meeting-warnings');
if (meetingAvgScore) {
meetingAvgScore.textContent = avgScore;
meetingAvgScore.style.color = avgScore < 50 ? 'var(--danger)' : (avgScore < 80 ? 'var(--warning)' : 'var(--success)');
if (document.getElementById('meeting-engagement-bar')) {
document.getElementById('meeting-engagement-bar').style.width = `${avgScore}%`;
}
}
if (pCount) pCount.textContent = data.participant_count || 0;
if (mWarnings) mWarnings.textContent = data.distracted_count || 0;
// New meeting-specific metrics
if (document.getElementById('meeting-yawn-count')) {
document.getElementById('meeting-yawn-count').textContent = data.yawn_count || 0;
}
if (document.getElementById('meeting-drowsy-count')) {
document.getElementById('meeting-drowsy-count').textContent = data.drowsy_count || 0;
}
if (data.signals) {
if (document.getElementById('meeting-gaze-score')) {
document.getElementById('meeting-gaze-score').textContent = `${Math.round((data.signals.gaze_score || 0) * 100)}%`;
}
if (document.getElementById('meeting-attention-score')) {
document.getElementById('meeting-attention-score').textContent = `${Math.round((data.signals.attention_score || data.signals.gaze_score || 0) * 100)}%`;
}
}
if (statusText) statusText.textContent = data.status_text;
return;
}
// Hero Score (Individual Mode)
const score = Math.round(data.score);
engagementScore.textContent = data.face_detected ? score : "--";
engagementBar.style.width = data.face_detected ? `${score}%` : "0%";
engagementScore.className = "score-value";
if (score < 50) engagementScore.classList.add('danger');
else if (score < 80) engagementScore.classList.add('warning');
// Status Text
statusText.textContent = data.face_detected ? data.status_text : "NO FACE DETECTED";
statusText.className = "status-text";
if (data.status_text === "SLEEPING") statusText.classList.add('danger');
else if (data.status_text === "YAWNING" || data.status_text === "DROWSY") statusText.classList.add('warning');
// Alerts
if (data.status_text === "SLEEPING" || data.drowsy_duration > 5) {
alertBanner.classList.remove('hidden');
} else {
alertBanner.classList.add('hidden');
}
// Stats
yawnCount.textContent = data.yawn_count || 0;
drowsyCount.textContent = data.drowsy_count || 0;
if (data.signals && data.face_detected) {
earValue.textContent = data.signals.eye_openness.toFixed(2);
const emotionIcons = {
'Happy': 'smile',
'Focused': 'crosshair',
'Neutral': 'meh',
'Tired': 'battery-low',
'Surprised': 'zap',
'Angry': 'frown',
'Sad': 'cloud-rain'
};
const elabel = data.signals.emotion_label || 'Neutral';
document.getElementById('emotion-icon').innerHTML = `<i data-lucide="${emotionIcons[elabel] || 'meh'}"></i>`;
emotionLabel.innerHTML = elabel;
gazeScore.textContent = `${Math.round((data.signals.gaze_score || 0) * 100)}%`;
stabilityScore.textContent = `${Math.round((data.signals.head_stability || 0) * 100)}%`;
qualityScore.textContent = `${Math.round((data.signals.face_quality || 0) * 100)}%`;
attentionScore.textContent = `${Math.round((data.signals.attention_score || data.signals.gaze_score || 0) * 100)}%`;
// Update debug overlay
const debug = document.getElementById('camera-debug-info');
if (debug) {
const sourcePrefix = captureSource === "screen" ? "🖥️ Screen" : "👤 Face";
debug.innerHTML = `${sourcePrefix}: Detected | EAR: ${data.signals.eye_openness.toFixed(3)} | Yaw: ${data.signals.yaw.toFixed(1)}°`;
debug.style.color = data.signals.eye_openness < (data.signals.ear_threshold || 0.22) ? '#ff5252' : '#4caf50';
}
} else {
// Reset detail metrics
earValue.textContent = "0.00";
gazeScore.textContent = "--%";
stabilityScore.textContent = "--%";
qualityScore.textContent = "--%";
attentionScore.textContent = "--%";
}
}
async function startSession(mode = "individual", useScreen = false) {
sessionMode = mode;
// Always switch to the appropriate view when starting
if (mode === "individual") {
switchView('live');
} else {
switchView('meeting');
}
const success = useScreen ? await startScreenShare() : await startCamera();
if (!success) return;
isRecording = true;
btnStart.classList.add('hidden');
btnStop.classList.remove('hidden');
statusIndicator.querySelector('.dot').classList.add('red');
startTime = Date.now();
timerInterval = setInterval(updateTimer, 1000);
connectWebSocket();
// Send frames at 5 FPS roughly
frameInterval = setInterval(sendFrame, 200);
}
function stopSession() {
isRecording = false;
btnStart.classList.remove('hidden');
btnStop.classList.add('hidden');
statusIndicator.querySelector('.dot').classList.remove('red');
clearInterval(timerInterval);
clearInterval(frameInterval);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: "stop_session", mode: sessionMode }));
// Give it a moment to send before closing
setTimeout(() => ws.close(), 100);
}
stopCamera();
// Reset UI metrics
engagementScore.textContent = "--";
engagementBar.style.width = "0%";
statusText.textContent = "FOCUSED";
yawnCount.textContent = "0";
drowsyCount.textContent = "0";
earValue.textContent = "0.00";
recordingTime.textContent = "00:00";
alertBanner.classList.add('hidden');
// Reset meeting cards
if (document.getElementById('meeting-avg-score')) document.getElementById('meeting-avg-score').textContent = "--";
if (document.getElementById('participant-count')) document.getElementById('participant-count').textContent = "0";
if (document.getElementById('meeting-warnings')) document.getElementById('meeting-warnings').textContent = "0";
// Small delay to allow DB update then jump to history
setTimeout(() => {
switchView('history');
if (sessionMode === "meeting") {
document.getElementById('tab-meetings').click();
} else {
document.getElementById('tab-individual').click();
}
}, 500);
}
async function loadHistory() {
historyList.innerHTML = '<div class="text-center" style="padding: 40px; color: var(--text-muted)">Loading sessions...</div>';
try {
const res = await fetch('/sessions');
const data = await res.json();
if (data.sessions.length === 0) {
historyList.innerHTML = `
<div style="text-center; padding: 40px; color: var(--text-muted);">
No sessions recorded yet. Go to Live Analysis to start one!
</div>
`;
return;
}
historyList.innerHTML = '';
data.sessions.forEach(session => {
const date = new Date(session.start_time);
const formatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
const score = Math.round(session.avg_focus_score || 0);
let scoreColor = "var(--success)";
if (score < 50) scoreColor = "var(--danger)";
else if (score < 80) scoreColor = "var(--warning)";
const el = document.createElement('div');
el.className = 'history-item glass-panel mt-20';
el.style.display = 'flex';
el.style.justifyContent = 'space-between';
el.style.alignItems = 'center';
el.innerHTML = `
<div>
<div class="history-date">Session #${session.session_id} - ${formatter.format(date)}</div>
<div style="color: var(--text-muted); font-size: 14px; margin-top:5px;">Frames Analyzed: ${session.total_frames || 0}</div>
</div>
<div style="text-align: right;">
<div class="history-score" style="color:${scoreColor}; font-size: 24px; font-weight: bold; font-family: 'Playfair Display', serif;">${score}</div>
<div style="color: var(--text-muted); font-size: 10px; text-transform:uppercase;">Avg Score</div>
</div>
`;
historyList.appendChild(el);
});
} catch (error) {
console.error("Failed to load history", error);
historyList.innerHTML = '<div class="alert-banner">Failed to load history. Make sure the server is running.</div>';
}
}
async function loadMeetings() {
historyList.innerHTML = '<div class="text-center" style="padding: 40px; color: var(--text-muted)">Loading meetings...</div>';
try {
const res = await fetch('/meetings');
const data = await res.json();
if (data.meetings.length === 0) {
historyList.innerHTML = '<div class="text-center" style="padding: 40px; color: var(--text-muted);">No meetings recorded yet.</div>';
return;
}
historyList.innerHTML = '';
data.meetings.forEach(meeting => {
const date = new Date(meeting.start_time);
const formatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
const score = Math.round(meeting.avg_engagement || 0);
const el = document.createElement('div');
el.className = 'history-item glass-panel mt-20';
el.style.display = 'flex';
el.style.justifyContent = 'space-between';
el.style.alignItems = 'center';
el.innerHTML = `
<div>
<div class="history-date">Meeting #${meeting.meeting_id} - ${meeting.notes || 'No Title'}</div>
<div style="color: var(--text-muted); font-size: 14px; margin-top:5px;">${formatter.format(date)} | Peak Participants: ${meeting.peak_participants || 0}</div>
</div>
<div style="text-align: right;">
<div class="history-score" style="color: var(--primary); font-size: 24px; font-weight: bold; font-family: 'Playfair Display', serif;">${score}</div>
<div style="color: var(--text-muted); font-size: 10px; text-transform:uppercase;">Group Avg</div>
</div>
`;
historyList.appendChild(el);
});
} catch (error) {
console.error("Failed to load meetings", error);
}
}
// Tab Listeners
document.getElementById('tab-individual')?.addEventListener('click', () => {
document.getElementById('tab-individual').classList.add('active');
document.getElementById('tab-meetings').classList.remove('active');
loadHistory();
});
document.getElementById('tab-meetings')?.addEventListener('click', () => {
document.getElementById('tab-meetings').classList.add('active');
document.getElementById('tab-individual').classList.remove('active');
loadMeetings();
});
btnStartMeeting?.addEventListener('click', () => {
startSession("meeting", false);
btnShareScreen.classList.remove('hidden');
});
btnShareScreen?.addEventListener('click', async () => {
if (!isRecording) {
// Start a new meeting session with screen share
startSession("meeting", true);
} else {
// If already recording, we need to switch the stream
const success = await startScreenShare();
if (success) {
console.log("Switched to screen share");
}
}
});
// Event Listeners
btnStart.addEventListener('click', () => {
// Context-aware start: if we are on the meeting page, start a meeting
if (!viewMeeting.classList.contains('hidden')) {
startSession("meeting", false);
} else {
startSession("individual", false);
}
});
btnStop.addEventListener('click', stopSession);
// Default open View
switchView('dashboard');
lucide.createIcons();
});