Spaces:
Sleeping
Sleeping
| // 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(() => {}); | |
| } | |
| } | |
| } | |
| } | |