// src/utils/VideoManager.js export class VideoManager { constructor(callbacks) { // callbacks 用于通知 React 组件更新界面 // 例如: onStatusUpdate, onSessionStart, onSessionEnd this.callbacks = callbacks || {}; this.videoElement = null; // 显示后端处理后的视频 this.stream = null; // 本地摄像头流 this.pc = null; this.dataChannel = null; this.isStreaming = false; this.sessionId = null; this.frameRate = 30; // 状态平滑处理 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秒 } // 初始化:获取摄像头流,并记录展示视频的元素 async initCamera(videoRef) { try { this.stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false }); this.videoElement = videoRef; return true; } catch (error) { console.error('Camera init error:', error); throw error; } } async startStreaming() { if (!this.stream) { console.error('❌ No stream available'); throw new Error('Camera stream not initialized'); } this.isStreaming = true; console.log('📹 Starting streaming...'); // 请求通知权限 await this.requestNotificationPermission(); // 加载通知设置 await this.loadNotificationSettings(); this.pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, { urls: 'stun:stun3.l.google.com:19302' }, { urls: 'stun:stun4.l.google.com:19302' } ], iceCandidatePoolSize: 10 }); // 添加连接状态监控 this.pc.onconnectionstatechange = () => { console.log('🔗 Connection state:', this.pc.connectionState); }; this.pc.oniceconnectionstatechange = () => { console.log('🧊 ICE connection state:', this.pc.iceConnectionState); }; this.pc.onicegatheringstatechange = () => { console.log('📡 ICE gathering state:', this.pc.iceGatheringState); }; // DataChannel for status updates this.dataChannel = this.pc.createDataChannel('status'); this.dataChannel.onmessage = (event) => { try { const data = JSON.parse(event.data); this.handleServerMessage(data); } catch (e) { console.error('Failed to parse data channel message:', e); } }; this.pc.ontrack = (event) => { const stream = event.streams[0]; if (this.videoElement) { this.videoElement.srcObject = stream; this.videoElement.autoplay = true; this.videoElement.playsInline = true; this.videoElement.play().catch(() => {}); } }; // Add local camera tracks this.stream.getTracks().forEach((track) => { this.pc.addTrack(track, this.stream); }); const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); // Wait for ICE gathering to complete so SDP includes candidates await new Promise((resolve) => { if (this.pc.iceGatheringState === 'complete') { resolve(); return; } const onIce = () => { if (this.pc.iceGatheringState === 'complete') { this.pc.removeEventListener('icegatheringstatechange', onIce); resolve(); } }; this.pc.addEventListener('icegatheringstatechange', onIce); }); console.log('📤 Sending offer to server...'); const response = await fetch('/api/webrtc/offer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sdp: this.pc.localDescription.sdp, type: this.pc.localDescription.type }) }); if (!response.ok) { const errorText = await response.text(); console.error('❌ Server error:', errorText); throw new Error(`Server returned ${response.status}: ${errorText}`); } const answer = await response.json(); console.log('✅ Received answer from server, session_id:', answer.session_id); await this.pc.setRemoteDescription(answer); console.log('✅ Remote description set successfully'); this.sessionId = answer.session_id; if (this.callbacks.onSessionStart) { this.callbacks.onSessionStart(this.sessionId); } } 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); } } } 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; if (this.callbacks.onStatusUpdate) { this.callbacks.onStatusUpdate(this.currentStatus); } break; default: 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; } } } } async stopStreaming() { this.isStreaming = false; try { if (document.pictureInPictureElement) { await document.exitPictureInPicture(); } if (this.videoElement && typeof this.videoElement.webkitSetPresentationMode === 'function') { if (this.videoElement.webkitPresentationMode === 'picture-in-picture') { this.videoElement.webkitSetPresentationMode('inline'); } } } catch (e) { // ignore PiP exit errors } if (this.pc) { try { this.pc.getSenders().forEach(sender => sender.track && sender.track.stop()); this.pc.close(); } catch (e) { console.error('Failed to close RTCPeerConnection:', e); } this.pc = null; } if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); this.stream = null; } if (this.videoElement) { this.videoElement.srcObject = null; } if (this.sessionId) { try { const response = await fetch('/api/sessions/end', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: this.sessionId }) }); const summary = await response.json(); if (this.callbacks.onSessionEnd) { this.callbacks.onSessionEnd(summary); } } catch (e) { console.error('Failed to end session:', e); } } // 清理通知状态 this.unfocusedStartTime = null; this.lastNotificationTime = null; this.sessionId = null; } setFrameRate(rate) { this.frameRate = Math.max(1, Math.min(60, rate)); if (this.stream) { const videoTrack = this.stream.getVideoTracks()[0]; if (videoTrack && videoTrack.applyConstraints) { videoTrack.applyConstraints({ frameRate: { ideal: this.frameRate, max: this.frameRate } }).catch(() => {}); } } } }