// ================ PAGE NAVIGATION ================ const pages = { 'page-a': document.getElementById('page-a'), 'page-b': document.getElementById('page-b'), 'page-c': document.getElementById('page-c'), // Achievement 'page-d': document.getElementById('page-d'), // Records 'page-e': document.getElementById('page-e'), // Customise 'page-f': document.getElementById('page-f') // Help }; function showPage(pageId) { for (const key in pages) { pages[key].classList.add('hidden'); } if (pages[pageId]) { pages[pageId].classList.remove('hidden'); } } // ================ VIDEO MANAGER CLASS ================ class VideoManager { constructor() { this.videoElement = null; this.canvasElement = null; this.ctx = null; this.captureCanvas = null; this.captureCtx = null; this.stream = null; this.ws = null; this.isStreaming = false; this.sessionId = null; this.frameRate = 30; this.frameInterval = null; this.renderLoopId = null; // Status smoothing for stable display this.currentStatus = false; // Default: not focused this.previousStatus = false; // Track previous status to detect changes this.statusBuffer = []; // Buffer for last N frames this.bufferSize = 5; // Number of frames to average (smaller = more responsive) // Latest detection data for rendering this.latestDetectionData = null; this.lastConfidence = 0; this.detectionHoldMs = 30; } async initCamera() { try { this.stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } }); this.videoElement = document.createElement('video'); this.videoElement.srcObject = this.stream; this.videoElement.autoplay = true; this.videoElement.playsInline = true; this.canvasElement = document.createElement('canvas'); this.canvasElement.width = 640; this.canvasElement.height = 480; this.ctx = this.canvasElement.getContext('2d'); this.captureCanvas = document.createElement('canvas'); this.captureCanvas.width = 640; this.captureCanvas.height = 480; this.captureCtx = this.captureCanvas.getContext('2d'); const displayArea = document.getElementById('display-area'); displayArea.innerHTML = ''; displayArea.appendChild(this.canvasElement); await this.videoElement.play(); this.startRenderLoop(); return true; } catch (error) { console.error('Camera init error:', error); throw error; } } connectWebSocket() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/video`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('WebSocket connected'); this.startSession(); }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); this.handleServerMessage(data); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; this.ws.onclose = () => { console.log('WebSocket closed'); }; } startStreaming() { this.isStreaming = true; this.connectWebSocket(); this.frameInterval = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.captureAndSendFrame(); } }, 1000 / this.frameRate); } captureAndSendFrame() { if (!this.videoElement || !this.captureCanvas || !this.captureCtx) return; this.captureCtx.drawImage(this.videoElement, 0, 0, 640, 480); const imageData = this.captureCanvas.toDataURL('image/jpeg', 0.8); const base64Data = imageData.split(',')[1]; if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'frame', image: base64Data })); } } handleServerMessage(data) { switch (data.type) { case 'detection': // Update status with smoothing this.updateStatus(data.focused); // Render with smoothed status this.renderDetections(data); // Update timeline and notifications with smoothed status timeline.addEvent(this.currentStatus); checkDistraction(this.currentStatus); break; case 'session_started': this.sessionId = data.session_id; console.log('Session started:', this.sessionId); break; case 'session_ended': console.log('Session ended:', data.summary); showSessionSummary(data.summary); break; case 'ack': // Frame acknowledged but not processed break; case 'error': console.error('Server error:', data.message); break; } } updateStatus(newFocused) { // Add to buffer this.statusBuffer.push(newFocused); // Keep buffer size limited if (this.statusBuffer.length > this.bufferSize) { this.statusBuffer.shift(); } // Don't update status until buffer is full (prevents initial flickering) if (this.statusBuffer.length < this.bufferSize) { return false; // Status hasn't changed } // Calculate majority vote with moderate thresholds for better responsiveness const focusedCount = this.statusBuffer.filter(f => f).length; const focusedRatio = focusedCount / this.statusBuffer.length; // Store previous status this.previousStatus = this.currentStatus; // Moderate thresholds: quicker to change, still avoids rapid flipping // For 8 frames: need 6+ focused to become FOCUSED, or 6+ not focused to become NOT FOCUSED if (focusedRatio >= 0.75) { this.currentStatus = true; } else if (focusedRatio <= 0.25) { this.currentStatus = false; } // Between 0.25-0.75: keep current status (hysteresis to avoid jitter) // Log only when status actually changes const statusChanged = this.currentStatus !== this.previousStatus; if (statusChanged) { console.log(`Status changed: ${this.previousStatus ? 'FOCUSED' : 'NOT FOCUSED'} -> ${this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'} (ratio: ${focusedRatio.toFixed(2)})`); } // Return whether status changed return statusChanged; } renderDetections(data) { this.latestDetectionData = { detections: data.detections || [], confidence: data.confidence || 0, focused: data.focused, timestamp: performance.now() }; this.lastConfidence = data.confidence || 0; } startRenderLoop() { if (this.renderLoopId) return; const render = () => { if (this.videoElement && this.ctx) { this.ctx.drawImage(this.videoElement, 0, 0, 640, 480); const now = performance.now(); const latest = this.latestDetectionData; const hasFresh = latest && (now - latest.timestamp) <= this.detectionHoldMs; // Draw detection boxes using last known data (prevents flicker) if (hasFresh && latest.detections.length > 0) { latest.detections.forEach(det => { const [x1, y1, x2, y2] = det.bbox; this.ctx.strokeStyle = this.currentStatus ? '#00FF00' : '#FF0000'; this.ctx.lineWidth = 3; this.ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; this.ctx.font = '16px Nunito'; const label = `${det.class_name} ${(det.confidence * 100).toFixed(1)}%`; this.ctx.fillText(label, x1, y1 - 5); }); } const statusText = this.currentStatus ? 'FOCUSED' : 'NOT FOCUSED'; this.ctx.fillStyle = this.currentStatus ? '#00FF00' : '#FF0000'; this.ctx.font = 'bold 24px Nunito'; this.ctx.fillText(statusText, 10, 30); this.ctx.font = '16px Nunito'; this.ctx.fillText(`Confidence: ${(this.lastConfidence * 100).toFixed(1)}%`, 10, 55); } this.renderLoopId = requestAnimationFrame(render); }; this.renderLoopId = requestAnimationFrame(render); } stopRenderLoop() { if (this.renderLoopId) { cancelAnimationFrame(this.renderLoopId); this.renderLoopId = null; } } startSession() { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'start_session' })); } } stopStreaming() { this.isStreaming = false; this.stopRenderLoop(); if (this.frameInterval) { clearInterval(this.frameInterval); this.frameInterval = null; } if (this.ws) { this.ws.send(JSON.stringify({ type: 'end_session' })); this.ws.close(); this.ws = null; } if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; } if (this.canvasElement && this.ctx) { this.ctx.clearRect(0, 0, 640, 480); } // Reset status this.currentStatus = false; this.statusBuffer = []; this.latestDetectionData = null; this.lastConfidence = 0; } setFrameRate(rate) { this.frameRate = Math.max(1, Math.min(60, rate)); if (this.isStreaming && this.frameInterval) { clearInterval(this.frameInterval); this.frameInterval = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.captureAndSendFrame(); } }, 1000 / this.frameRate); } } } // ================ TIMELINE MANAGER CLASS ================ class TimelineManager { constructor(maxEvents = 60) { this.events = []; this.maxEvents = maxEvents; this.container = document.getElementById('timeline-visuals'); } addEvent(isFocused) { const timestamp = Date.now(); this.events.push({ timestamp, isFocused }); if (this.events.length > this.maxEvents) { this.events.shift(); } this.render(); } render() { if (!this.container) return; this.container.innerHTML = ''; this.events.forEach((event, index) => { const block = document.createElement('div'); block.className = 'timeline-block'; block.style.backgroundColor = event.isFocused ? '#00FF00' : '#FF0000'; block.style.width = '10px'; block.style.height = '20px'; block.style.display = 'inline-block'; block.style.marginRight = '2px'; block.title = event.isFocused ? 'Focused' : 'Distracted'; this.container.appendChild(block); }); } clear() { this.events = []; this.render(); } } // ================ NOTIFICATION SYSTEM ================ let distractionStartTime = null; let notificationTimeout = null; let currentSettings = null; async function loadCurrentSettings() { try { const response = await fetch('/api/settings'); currentSettings = await response.json(); } catch (error) { console.error('Failed to load settings:', error); currentSettings = { notification_enabled: true, notification_threshold: 30 }; } } function checkDistraction(isFocused) { if (!currentSettings || !currentSettings.notification_enabled) return; if (!isFocused) { if (!distractionStartTime) { distractionStartTime = Date.now(); } const distractionDuration = (Date.now() - distractionStartTime) / 1000; if (distractionDuration >= currentSettings.notification_threshold && !notificationTimeout) { sendNotification('Focus Guard Alert', 'You seem distracted. Time to refocus!'); notificationTimeout = setTimeout(() => { notificationTimeout = null; }, 60000); } } else { distractionStartTime = null; } } async function sendNotification(title, body) { if ('Notification' in window) { if (Notification.permission === 'granted') { new Notification(title, { body }); } else if (Notification.permission !== 'denied') { const permission = await Notification.requestPermission(); if (permission === 'granted') { new Notification(title, { body }); } } } } // ================ SESSION SUMMARY MODAL ================ function showSessionSummary(summary) { const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = `
`; document.body.appendChild(modal); } function closeModal() { const modal = document.querySelector('.modal-overlay'); if (modal) { modal.remove(); } } function formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (hours > 0) { return `${hours}h ${minutes}m ${secs}s`; } else if (minutes > 0) { return `${minutes}m ${secs}s`; } else { return `${secs}s`; } } // ================ GLOBAL INSTANCES ================ const videoManager = new VideoManager(); const timeline = new TimelineManager(); // ================ EVENT LISTENERS ================ // Page navigation document.getElementById('menu-start').addEventListener('click', () => showPage('page-b')); document.getElementById('menu-achievement').addEventListener('click', () => { showPage('page-c'); loadAchievements(); }); document.getElementById('menu-records').addEventListener('click', () => { showPage('page-d'); loadRecords('today'); }); document.getElementById('menu-customise').addEventListener('click', () => { showPage('page-e'); loadSettings(); }); document.getElementById('menu-help').addEventListener('click', () => showPage('page-f')); document.getElementById('start-button').addEventListener('click', () => showPage('page-b')); // Page B controls document.getElementById('btn-cam-start').addEventListener('click', async () => { try { await videoManager.initCamera(); videoManager.startStreaming(); timeline.clear(); await loadCurrentSettings(); } catch (error) { console.error('Failed to start camera:', error); alert('Camera access denied. Please allow camera permissions and ensure you are using HTTPS or localhost.'); } }); document.getElementById('btn-cam-stop').addEventListener('click', () => { videoManager.stopStreaming(); }); document.getElementById('btn-floating').addEventListener('click', () => { alert('Floating window feature coming soon!'); }); document.getElementById('btn-models').addEventListener('click', () => { alert('Model selection feature coming soon!'); }); // Frame control const frameSlider = document.getElementById('frame-slider'); const frameInput = document.getElementById('frame-input'); frameSlider.addEventListener('input', (e) => { const rate = parseInt(e.target.value); frameInput.value = rate; videoManager.setFrameRate(rate); }); frameInput.addEventListener('input', (e) => { const rate = parseInt(e.target.value); frameSlider.value = rate; videoManager.setFrameRate(rate); }); // ================ ACHIEVEMENT PAGE ================ async function loadAchievements() { try { const response = await fetch('/api/stats/summary'); const stats = await response.json(); document.getElementById('total-sessions').textContent = stats.total_sessions; document.getElementById('total-hours').textContent = (stats.total_focus_time / 3600).toFixed(1) + 'h'; document.getElementById('avg-focus').textContent = (stats.avg_focus_score * 100).toFixed(1) + '%'; document.getElementById('current-streak').textContent = stats.streak_days; loadBadges(stats); } catch (error) { console.error('Failed to load achievements:', error); } } function loadBadges(stats) { const badges = [ { name: 'First Session', condition: stats.total_sessions >= 1, icon: '' }, { name: '10 Sessions', condition: stats.total_sessions >= 10, icon: '' }, { name: '50 Sessions', condition: stats.total_sessions >= 50, icon: '' }, { name: '10 Hour Focus', condition: stats.total_focus_time >= 36000, icon: '' }, { name: '7 Day Streak', condition: stats.streak_days >= 7, icon: '' }, { name: '90% Avg Focus', condition: stats.avg_focus_score >= 0.9, icon: '' } ]; const container = document.getElementById('badges-container'); container.innerHTML = ''; badges.forEach(badge => { const badgeEl = document.createElement('div'); badgeEl.className = 'badge ' + (badge.condition ? 'earned' : 'locked'); badgeEl.innerHTML = `