FocusGuardBaseModel / src /utils /VideoManager.js
Kexin-251202's picture
Deploy base model
c86c45b verified
// 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(() => {});
}
}
}
}