FocusGuardBaseModel / src /components /FocusPageLocal.jsx
Kexin-251202's picture
Deploy base model
c86c45b verified
import React, { useState, useEffect, useRef } from 'react';
function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive }) {
const [currentFrame, setCurrentFrame] = useState(15);
const [timelineEvents, setTimelineEvents] = useState([]);
const [stats, setStats] = useState(null);
const localVideoRef = useRef(null);
const displayCanvasRef = useRef(null);
const pipVideoRef = useRef(null); // 用于 PiP 的隐藏 video 元素
const pipStreamRef = useRef(null);
// 辅助函数:格式化时间
const formatDuration = (seconds) => {
if (seconds === 0) return "0s";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}m ${secs}s`;
};
useEffect(() => {
if (!videoManager) return;
// 设置回调函数来更新时间轴
const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
videoManager.callbacks.onStatusUpdate = (isFocused) => {
setTimelineEvents(prev => {
const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
if (newEvents.length > 60) newEvents.shift();
return newEvents;
});
if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
};
// 定期更新统计信息
const statsInterval = setInterval(() => {
if (videoManager && videoManager.getStats) {
setStats(videoManager.getStats());
}
}, 1000);
return () => {
if (videoManager) {
videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
}
clearInterval(statsInterval);
};
}, [videoManager]);
const handleStart = async () => {
try {
if (videoManager) {
setSessionResult(null);
setTimelineEvents([]);
console.log('Initializing local camera...');
await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
console.log('Camera initialized');
console.log('Starting local streaming...');
await videoManager.startStreaming();
console.log('Streaming started successfully');
}
} catch (err) {
console.error('Start error:', err);
let errorMessage = "Failed to start: ";
if (err.name === 'NotAllowedError') {
errorMessage += "Camera permission denied. Please allow camera access.";
} else if (err.name === 'NotFoundError') {
errorMessage += "No camera found. Please connect a camera.";
} else if (err.name === 'NotReadableError') {
errorMessage += "Camera is already in use by another application.";
} else {
errorMessage += err.message || "Unknown error occurred.";
}
alert(errorMessage + "\n\nCheck browser console for details.");
}
};
const handleStop = async () => {
if (videoManager) {
videoManager.stopStreaming();
}
try {
if (document.pictureInPictureElement === pipVideoRef.current) {
await document.exitPictureInPicture();
}
} catch (_) {}
if (pipVideoRef.current) {
pipVideoRef.current.pause();
pipVideoRef.current.srcObject = null;
}
if (pipStreamRef.current) {
pipStreamRef.current.getTracks().forEach(t => t.stop());
pipStreamRef.current = null;
}
};
const handlePiP = async () => {
try {
// 检查是否有视频管理器和是否在运行
if (!videoManager || !videoManager.isStreaming) {
alert('Please start the video first.');
return;
}
if (!displayCanvasRef.current) {
alert('Video not ready.');
return;
}
// 如果已经在 PiP 模式,且是本视频,退出
if (document.pictureInPictureElement === pipVideoRef.current) {
await document.exitPictureInPicture();
console.log('PiP exited');
return;
}
// 检查浏览器支持
if (!document.pictureInPictureEnabled) {
alert('Picture-in-Picture is not supported in this browser.');
return;
}
// 创建或获取 PiP video 元素
const pipVideo = pipVideoRef.current;
if (!pipVideo) {
alert('PiP video element not ready.');
return;
}
const isSafariPiP = typeof pipVideo.webkitSetPresentationMode === 'function';
// 优先用画布流(带检测框),失败再回退到摄像头流
let stream = pipStreamRef.current;
if (!stream) {
const capture = displayCanvasRef.current.captureStream;
if (typeof capture === 'function') {
stream = capture.call(displayCanvasRef.current, 30);
}
if (!stream || stream.getTracks().length === 0) {
const cameraStream = localVideoRef.current?.srcObject;
if (!cameraStream) {
alert('Camera stream not ready.');
return;
}
stream = cameraStream;
}
pipStreamRef.current = stream;
}
// 确保流有轨道
if (!stream || stream.getTracks().length === 0) {
alert('Failed to capture video stream from canvas.');
return;
}
pipVideo.srcObject = stream;
// 播放视频(Safari 可能不会触发 onloadedmetadata)
if (pipVideo.readyState < 2) {
await new Promise((resolve) => {
const onReady = () => {
pipVideo.removeEventListener('loadeddata', onReady);
pipVideo.removeEventListener('canplay', onReady);
resolve();
};
pipVideo.addEventListener('loadeddata', onReady);
pipVideo.addEventListener('canplay', onReady);
// 兜底:短延迟后继续尝试
setTimeout(resolve, 600);
});
}
try {
await pipVideo.play();
} catch (_) {
// Safari 可能拒绝自动播放,但仍可进入 PiP
}
// Safari 支持(优先)
if (isSafariPiP) {
try {
pipVideo.webkitSetPresentationMode('picture-in-picture');
console.log('PiP activated (Safari)');
return;
} catch (e) {
// 如果画布流失败,回退到摄像头流再试一次
const cameraStream = localVideoRef.current?.srcObject;
if (cameraStream && cameraStream !== pipVideo.srcObject) {
pipVideo.srcObject = cameraStream;
try {
await pipVideo.play();
} catch (_) {}
pipVideo.webkitSetPresentationMode('picture-in-picture');
console.log('PiP activated (Safari fallback)');
return;
}
throw e;
}
}
// 标准 API
if (typeof pipVideo.requestPictureInPicture === 'function') {
await pipVideo.requestPictureInPicture();
console.log('PiP activated');
} else {
alert('Picture-in-Picture is not supported in this browser.');
}
} catch (err) {
console.error('PiP error:', err);
alert('Failed to enter Picture-in-Picture: ' + err.message);
}
};
const handleFloatingWindow = () => {
handlePiP();
};
const handleFrameChange = (val) => {
const rate = parseInt(val);
setCurrentFrame(rate);
if (videoManager) {
videoManager.setFrameRate(rate);
}
};
const handlePreview = () => {
if (!videoManager || !videoManager.isStreaming) {
alert('Please start a session first.');
return;
}
// 获取当前统计数据
const currentStats = videoManager.getStats();
if (!currentStats.sessionId) {
alert('No active session.');
return;
}
// 计算当前持续时间(从 session 开始到现在)
const sessionDuration = Math.floor((Date.now() - (videoManager.sessionStartTime || Date.now())) / 1000);
// 计算当前专注分数
const focusScore = currentStats.framesProcessed > 0
? (currentStats.framesProcessed * (currentStats.currentStatus ? 1 : 0)) / currentStats.framesProcessed
: 0;
// 显示当前实时数据
setSessionResult({
duration_seconds: sessionDuration,
focus_score: focusScore,
total_frames: currentStats.framesProcessed,
focused_frames: Math.floor(currentStats.framesProcessed * focusScore)
});
};
const handleCloseOverlay = () => {
setSessionResult(null);
};
const pageStyle = isActive
? undefined
: {
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
opacity: 0,
pointerEvents: 'none'
};
useEffect(() => {
return () => {
if (pipVideoRef.current) {
pipVideoRef.current.pause();
pipVideoRef.current.srcObject = null;
}
if (pipStreamRef.current) {
pipStreamRef.current.getTracks().forEach(t => t.stop());
pipStreamRef.current = null;
}
};
}, []);
return (
<main id="page-b" className="page" style={pageStyle}>
{/* 1. Camera / Display Area */}
<section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
{/* 用于 PiP 的隐藏 video 元素(保持在 DOM 以提高兼容性) */}
<video
ref={pipVideoRef}
muted
playsInline
autoPlay
style={{
position: 'absolute',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none'
}}
/>
{/* 本地视频流(隐藏,仅用于截图) */}
<video
ref={localVideoRef}
muted
playsInline
autoPlay
style={{ display: 'none' }}
/>
{/* 显示处理后的视频(使用 Canvas) */}
<canvas
ref={displayCanvasRef}
width={640}
height={480}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
backgroundColor: '#000'
}}
/>
{/* 结果覆盖层 */}
{sessionResult && (
<div className="session-result-overlay">
<h3>Session Complete!</h3>
<div className="result-item">
<span className="label">Duration:</span>
<span className="value">{formatDuration(sessionResult.duration_seconds)}</span>
</div>
<div className="result-item">
<span className="label">Focus Score:</span>
<span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
</div>
<button
onClick={handleCloseOverlay}
style={{
marginTop: '20px',
padding: '8px 20px',
background: 'transparent',
border: '1px solid white',
color: 'white',
borderRadius: '20px',
cursor: 'pointer'
}}
>
Close
</button>
</div>
)}
{/* 性能统计显示(开发模式) */}
{stats && stats.isStreaming && (
<div style={{
position: 'absolute',
top: '10px',
right: '10px',
background: 'rgba(0,0,0,0.7)',
color: 'white',
padding: '10px',
borderRadius: '5px',
fontSize: '12px',
fontFamily: 'monospace'
}}>
<div>Session: {stats.sessionId}</div>
<div>Sent: {stats.framesSent}</div>
<div>Processed: {stats.framesProcessed}</div>
<div>Latency: {stats.avgLatency.toFixed(0)}ms</div>
<div>Status: {stats.currentStatus ? 'Focused' : 'Not Focused'}</div>
<div>Confidence: {(stats.lastConfidence * 100).toFixed(1)}%</div>
</div>
)}
</section>
{/* 2. Timeline Area */}
<section id="timeline-area">
<div className="timeline-label">Timeline</div>
<div id="timeline-visuals">
{timelineEvents.map((event, index) => (
<div
key={index}
className="timeline-block"
style={{
backgroundColor: event.isFocused ? '#00FF00' : '#FF0000',
width: '10px',
height: '20px',
display: 'inline-block',
marginRight: '2px',
borderRadius: '2px'
}}
title={event.isFocused ? 'Focused' : 'Distracted'}
/>
))}
</div>
<div id="timeline-line"></div>
</section>
{/* 3. Control Buttons */}
<section id="control-panel">
<button id="btn-cam-start" className="action-btn green" onClick={handleStart}>
Start
</button>
<button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>
Floating Window
</button>
<button
id="btn-preview"
className="action-btn"
style={{ backgroundColor: '#6c5ce7' }}
onClick={handlePreview}
>
Preview Result
</button>
<button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>
Stop
</button>
</section>
{/* 4. Frame Control */}
<section id="frame-control">
<label htmlFor="frame-slider">Frame Rate (FPS)</label>
<input
type="range"
id="frame-slider"
min="5"
max="30"
value={currentFrame}
onChange={(e) => handleFrameChange(e.target.value)}
/>
<input
type="number"
id="frame-input"
min="5"
max="30"
value={currentFrame}
onChange={(e) => handleFrameChange(e.target.value)}
/>
</section>
</main>
);
}
export default FocusPageLocal;