// ================ 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 = `
${badge.icon}
${badge.name}
`; container.appendChild(badgeEl); }); } // ================ RECORDS PAGE ================ async function loadRecords(filter = 'today') { try { const response = await fetch(`/api/sessions?filter=${filter}`); const sessions = await response.json(); renderSessionsTable(sessions); renderChart(sessions); } catch (error) { console.error('Failed to load records:', error); } } function renderSessionsTable(sessions) { const tbody = document.getElementById('sessions-tbody'); tbody.innerHTML = ''; sessions.forEach(session => { const row = document.createElement('tr'); const date = new Date(session.start_time).toLocaleString(); const duration = formatDuration(session.duration_seconds); const score = (session.focus_score * 100).toFixed(1) + '%'; row.innerHTML = ` ${date} ${duration} ${score} `; tbody.appendChild(row); }); if (sessions.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No sessions found'; tbody.appendChild(row); } } function renderChart(sessions) { const canvas = document.getElementById('focus-chart'); const ctx = canvas.getContext('2d'); canvas.width = 800; canvas.height = 300; ctx.clearRect(0, 0, canvas.width, canvas.height); if (sessions.length === 0) { ctx.fillStyle = '#888'; ctx.font = '20px Nunito'; ctx.fillText('No data available', canvas.width / 2 - 80, canvas.height / 2); return; } const barWidth = Math.min((canvas.width - 40) / sessions.length - 10, 80); const maxScore = 1.0; sessions.forEach((session, index) => { const x = index * (barWidth + 10) + 20; const barHeight = (session.focus_score / maxScore) * (canvas.height - 60); const y = canvas.height - barHeight - 30; ctx.fillStyle = session.focus_score > 0.7 ? '#28a745' : session.focus_score > 0.4 ? '#ffc107' : '#dc3545'; ctx.fillRect(x, y, barWidth, barHeight); ctx.fillStyle = '#333'; ctx.font = '12px Nunito'; const scoreText = (session.focus_score * 100).toFixed(0) + '%'; ctx.fillText(scoreText, x + barWidth / 2 - 15, y - 5); }); } function viewSessionDetails(sessionId) { alert(`Session details for ID ${sessionId} - Feature coming soon!`); } // Filter buttons document.getElementById('filter-today').addEventListener('click', () => { setActiveFilter('filter-today'); loadRecords('today'); }); document.getElementById('filter-week').addEventListener('click', () => { setActiveFilter('filter-week'); loadRecords('week'); }); document.getElementById('filter-month').addEventListener('click', () => { setActiveFilter('filter-month'); loadRecords('month'); }); document.getElementById('filter-all').addEventListener('click', () => { setActiveFilter('filter-all'); loadRecords('all'); }); function setActiveFilter(activeId) { document.querySelectorAll('.filter-btn').forEach(btn => { btn.classList.remove('active'); }); document.getElementById(activeId).classList.add('active'); } // ================ SETTINGS PAGE ================ async function loadSettings() { try { const response = await fetch('/api/settings'); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const settings = await response.json(); console.log('Loaded settings:', settings); // Apply settings with fallback to defaults document.getElementById('sensitivity-slider').value = settings.sensitivity || 6; document.getElementById('sensitivity-value').textContent = settings.sensitivity || 6; document.getElementById('default-framerate').value = settings.frame_rate || 30; document.getElementById('framerate-value').textContent = settings.frame_rate || 30; document.getElementById('enable-notifications').checked = settings.notification_enabled !== false; document.getElementById('notification-threshold').value = settings.notification_threshold || 30; } catch (error) { console.error('Failed to load settings:', error); alert('Failed to load settings: ' + error.message); } } async function saveSettings() { const settings = { sensitivity: parseInt(document.getElementById('sensitivity-slider').value), frame_rate: parseInt(document.getElementById('default-framerate').value), notification_enabled: document.getElementById('enable-notifications').checked, notification_threshold: parseInt(document.getElementById('notification-threshold').value) }; console.log('Saving settings:', settings); try { const response = await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); if (response.ok) { const result = await response.json(); console.log('Settings saved:', result); alert('Settings saved successfully!'); await loadCurrentSettings(); } else { const error = await response.text(); console.error('Save failed with status:', response.status, error); alert(`Failed to save settings: ${response.status} ${response.statusText}`); } } catch (error) { console.error('Failed to save settings:', error); alert('Failed to save settings: ' + error.message); } } // Settings UI handlers document.getElementById('sensitivity-slider').addEventListener('input', (e) => { document.getElementById('sensitivity-value').textContent = e.target.value; }); document.getElementById('default-framerate').addEventListener('input', (e) => { document.getElementById('framerate-value').textContent = e.target.value; }); document.getElementById('save-settings').addEventListener('click', saveSettings); document.getElementById('export-data').addEventListener('click', async () => { try { const response = await fetch('/api/sessions?filter=all'); const sessions = await response.json(); const dataStr = JSON.stringify(sessions, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `focus-guard-data-${new Date().toISOString().split('T')[0]}.json`; link.click(); URL.revokeObjectURL(url); } catch (error) { console.error('Failed to export data:', error); alert('Failed to export data'); } }); document.getElementById('clear-history').addEventListener('click', async () => { if (confirm('Are you sure you want to clear all history? This cannot be undone.')) { alert('Clear history feature requires backend implementation'); } }); // ================ INITIALIZATION ================ // Request notification permission on load if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } // Load settings on startup loadCurrentSettings(); console.log(' Focus Guard initialized');