Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, displayVideoRef }) { | |
| const [currentFrame, setCurrentFrame] = useState(30); | |
| const [timelineEvents, setTimelineEvents] = useState([]); | |
| const videoRef = displayVideoRef; | |
| // 辅助函数:格式化时间 | |
| const formatDuration = (seconds) => { | |
| // 如果是 0,直接显示 0s (或者你可以保留原来的 0m 0s) | |
| 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); | |
| }; | |
| // 清理函数:不再自动停止session,只清理回调 | |
| return () => { | |
| if (videoManager) { | |
| videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate; | |
| } | |
| }; | |
| }, [videoManager]); | |
| const handleStart = async () => { | |
| try { | |
| if (videoManager) { | |
| setSessionResult(null); // 开始时清除结果层 | |
| setTimelineEvents([]); | |
| console.log('🎬 Initializing camera...'); | |
| await videoManager.initCamera(videoRef.current); | |
| console.log('✅ Camera initialized'); | |
| console.log('🚀 Starting 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 if (err.message && err.message.includes('HTTPS')) { | |
| errorMessage += "Camera requires HTTPS. Please use a secure connection."; | |
| } else { | |
| errorMessage += err.message || "Unknown error occurred."; | |
| } | |
| alert(errorMessage + "\n\nCheck browser console for details."); | |
| } | |
| }; | |
| const handleStop = () => { | |
| if (videoManager) { | |
| videoManager.stopStreaming(); | |
| } | |
| }; | |
| const handlePiP = async () => { | |
| try { | |
| const sourceVideoEl = videoRef.current; | |
| if (!sourceVideoEl) { | |
| alert('Video not ready. Please click Start first.'); | |
| return; | |
| } | |
| if (document.pictureInPictureElement) { | |
| await document.exitPictureInPicture(); | |
| return; | |
| } | |
| sourceVideoEl.disablePictureInPicture = false; | |
| if (typeof sourceVideoEl.webkitSetPresentationMode === 'function') { | |
| sourceVideoEl.play().catch(() => {}); | |
| sourceVideoEl.webkitSetPresentationMode('picture-in-picture'); | |
| return; | |
| } | |
| if (!document.pictureInPictureEnabled || typeof sourceVideoEl.requestPictureInPicture !== 'function') { | |
| alert('Picture-in-Picture is not supported in this browser.'); | |
| return; | |
| } | |
| const pipPromise = sourceVideoEl.requestPictureInPicture(); | |
| sourceVideoEl.play().catch(() => {}); | |
| await pipPromise; | |
| } catch (err) { | |
| console.error('PiP error:', err); | |
| alert('Failed to enter Picture-in-Picture.'); | |
| } | |
| }; | |
| // 浮窗功能 | |
| const handleFloatingWindow = () => { | |
| handlePiP(); | |
| }; | |
| // ========================================== | |
| // 新增功能:预览按钮的处理函数 | |
| // ========================================== | |
| const handlePreview = () => { | |
| // 强制设置一个 0 分 0 秒的假数据,触发 overlay 显示 | |
| setSessionResult({ | |
| duration_seconds: 0, | |
| focus_score: 0 | |
| }); | |
| }; | |
| const handleCloseOverlay = () => { | |
| setSessionResult(null); | |
| }; | |
| // ========================================== | |
| const handleFrameChange = (val) => { | |
| setCurrentFrame(val); | |
| if (videoManager) { | |
| videoManager.setFrameRate(val); | |
| } | |
| }; | |
| const pageStyle = isActive | |
| ? undefined | |
| : { | |
| position: 'absolute', | |
| width: '1px', | |
| height: '1px', | |
| overflow: 'hidden', | |
| opacity: 0, | |
| pointerEvents: 'none' | |
| }; | |
| return ( | |
| <main id="page-b" className="page" style={pageStyle}> | |
| {/* 1. Camera / Display Area */} | |
| <section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}> | |
| <video | |
| ref={videoRef} | |
| muted | |
| playsInline | |
| autoPlay | |
| style={{ width: '100%', height: '100%', objectFit: 'contain' }} | |
| /> | |
| {/* 结果覆盖层 */} | |
| {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> | |
| )} | |
| </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> | |
| {/* 修改:把 Models 按钮暂时改成 Preview 按钮,或者加在它后面 */} | |
| <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</label> | |
| <input | |
| type="range" | |
| id="frame-slider" | |
| min="1" | |
| max="60" | |
| value={currentFrame} | |
| onChange={(e) => handleFrameChange(e.target.value)} | |
| /> | |
| <input | |
| type="number" | |
| id="frame-input" | |
| value={currentFrame} | |
| onChange={(e) => handleFrameChange(e.target.value)} | |
| /> | |
| </section> | |
| </main> | |
| ); | |
| } | |
| export default FocusPage; | |