// src/utils/VideoManager.js export class VideoManager { constructor(callbacks) { // callbacks 用于通知 React 组件更新界面 // 例如: onStatusChange, onFrameData, onSessionStart, onSessionEnd this.callbacks = callbacks || {}; 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; // 状态平滑处理 this.currentStatus = false; this.statusBuffer = []; this.bufferSize = 5; // 检测数据 this.latestDetectionData = null; this.lastConfidence = 0; this.detectionHoldMs = 30; // 通知系统 this.notificationEnabled = true; this.notificationThreshold = 30; // 默认30秒 this.unfocusedStartTime = null; this.lastNotificationTime = null; this.notificationCooldown = 60000; // 通知冷却时间60秒 } // 初始化:接收 React 传递过来的 video 和 canvas 引用 async initCamera(videoRef, canvasRef) { try { this.stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } }); this.videoElement = videoRef; this.videoElement.srcObject = this.stream; this.videoElement.autoplay = true; this.videoElement.playsInline = true; this.canvasElement = canvasRef; this.canvasElement.width = 640; this.canvasElement.height = 480; this.ctx = this.canvasElement.getContext('2d'); // 用于截图发送给后端的隐藏 Canvas this.captureCanvas = document.createElement('canvas'); this.captureCanvas.width = 640; this.captureCanvas.height = 480; this.captureCtx = this.captureCanvas.getContext('2d'); await this.videoElement.play(); this.startRenderLoop(); return true; } catch (error) { console.error('Camera init error:', error); throw error; } } connectWebSocket() { // 自动判断是加密的 wss 还是普通的 ws const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // 自动获取当前的主机地址 (比如 huggingface.co/...) const host = window.location.host; // 拼接完整的 WebSocket 地址 // 注意:这里假设后端路由是 /ws/video const wsUrl = `${protocol}//${host}/ws/video`; console.log("Connecting to WebSocket:", wsUrl); // 方便调试 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'); }; } async startStreaming() { if (!this.stream) return; this.isStreaming = true; // 请求通知权限 await this.requestNotificationPermission(); // 加载通知设置 await this.loadNotificationSettings(); this.connectWebSocket(); this.frameInterval = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.captureAndSendFrame(); } }, 1000 / this.frameRate); } async requestNotificationPermission() { if ('Notification' in window && Notification.permission === 'default') { try { await Notification.requestPermission(); } catch (error) { console.error('Failed to request notification permission:', error); } } } async loadNotificationSettings() { try { const response = await fetch('/api/settings'); const settings = await response.json(); if (settings) { this.notificationEnabled = settings.notification_enabled ?? true; this.notificationThreshold = settings.notification_threshold ?? 30; } } catch (error) { console.error('Failed to load notification settings:', error); } } sendNotification(title, message) { if (!this.notificationEnabled) return; if ('Notification' in window && Notification.permission === 'granted') { try { const notification = new Notification(title, { body: message, icon: '/vite.svg', badge: '/vite.svg', tag: 'focus-guard-distraction', requireInteraction: false }); // 3秒后自动关闭 setTimeout(() => notification.close(), 3000); } catch (error) { console.error('Failed to send notification:', error); } } } 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': this.updateStatus(data.focused); this.latestDetectionData = { detections: data.detections || [], confidence: data.confidence || 0, focused: data.focused, timestamp: performance.now() }; this.lastConfidence = data.confidence || 0; // 通知 React 更新时间轴状态 if(this.callbacks.onStatusUpdate) { this.callbacks.onStatusUpdate(this.currentStatus); } break; case 'session_started': this.sessionId = data.session_id; console.log('Session started:', this.sessionId); // 通知 React session已启动 if(this.callbacks.onSessionStart) { this.callbacks.onSessionStart(this.sessionId); } break; case 'session_ended': console.log('Session ended:', data.summary); // 通知 React 显示弹窗 if(this.callbacks.onSessionEnd) { this.callbacks.onSessionEnd(data.summary); } break; } } updateStatus(newFocused) { this.statusBuffer.push(newFocused); if (this.statusBuffer.length > this.bufferSize) { this.statusBuffer.shift(); } if (this.statusBuffer.length < this.bufferSize) return false; const focusedCount = this.statusBuffer.filter(f => f).length; const focusedRatio = focusedCount / this.statusBuffer.length; const previousStatus = this.currentStatus; if (focusedRatio >= 0.75) { this.currentStatus = true; } else if (focusedRatio <= 0.25) { this.currentStatus = false; } // 通知逻辑 this.handleNotificationLogic(previousStatus, this.currentStatus); } handleNotificationLogic(previousStatus, currentStatus) { const now = Date.now(); // 如果从专注变为不专注,记录开始时间 if (previousStatus && !currentStatus) { this.unfocusedStartTime = now; } // 如果从不专注变为专注,清除计时 if (!previousStatus && currentStatus) { this.unfocusedStartTime = null; } // 如果持续不专注 if (!currentStatus && this.unfocusedStartTime) { const unfocusedDuration = (now - this.unfocusedStartTime) / 1000; // 秒 // 检查是否超过阈值且不在冷却期 if (unfocusedDuration >= this.notificationThreshold) { const canSendNotification = !this.lastNotificationTime || (now - this.lastNotificationTime) >= this.notificationCooldown; if (canSendNotification) { this.sendNotification( '⚠️ Focus Alert', `You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!` ); this.lastNotificationTime = now; } } } } startRenderLoop() { if (this.renderLoopId) return; const render = () => { if (this.videoElement && this.ctx) { // 1. 绘制视频底图 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; // 2. 绘制检测框 (Logic preserved) 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); }); } // 3. 绘制状态文字 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); 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; } // 清理 Canvas if (this.ctx) this.ctx.clearRect(0, 0, 640, 480); // 清理通知状态 this.unfocusedStartTime = null; this.lastNotificationTime = null; } 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); } } }