Spaces:
Sleeping
Sleeping
| 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; | |