import React, { useEffect, useState, useRef, useCallback } from 'react'; import { getWsUrl } from '../api'; interface ProctorStreamPanelsProps { studentId: number; layout?: 'default' | 'focus'; } const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3]; export const ProctorStreamPanels: React.FC = ({ studentId, layout = 'focus', }) => { const [screenFrame, setScreenFrame] = useState(null); const [webcamFrame, setWebcamFrame] = useState(null); const [streaming, setStreaming] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [showWebcam, setShowWebcam] = useState(true); const [screenZoomIdx, setScreenZoomIdx] = useState(2); // 1x const [webcamZoomIdx, setWebcamZoomIdx] = useState(2); const [isFullscreen, setIsFullscreen] = useState(false); const wsRef = useRef(null); const intentionalClose = useRef(false); const hasOpened = useRef(false); const hasFrames = useRef(false); const screenPanelRef = useRef(null); const screenZoom = ZOOM_STEPS[screenZoomIdx]; const webcamZoom = ZOOM_STEPS[webcamZoomIdx]; useEffect(() => { const wsUrl = getWsUrl('/ws?role=teacher'); let retryTimer: ReturnType | null = null; let pingTimer: ReturnType | null = null; let attempt = 0; intentionalClose.current = false; hasOpened.current = false; hasFrames.current = false; setStreaming(false); setErrorMessage(null); setScreenFrame(null); setWebcamFrame(null); const markStreaming = () => { if (!hasFrames.current) { hasFrames.current = true; setStreaming(true); setErrorMessage(null); } }; const clearPing = () => { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } }; const connect = () => { if (intentionalClose.current) return; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { attempt = 0; hasOpened.current = true; setStreaming(true); setErrorMessage(null); ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } })); clearPing(); pingTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ event: 'client:ping', data: {} })); } }, 15000); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.event === 'client:pong') return; if (msg.event === 'teacher:screenshot-stream-frame' && msg.data.studentId == studentId) { setScreenFrame(msg.data.imageBuffer); markStreaming(); } else if (msg.event === 'teacher:webcam-stream-frame' && msg.data.studentId == studentId) { setWebcamFrame(msg.data.imageBuffer); markStreaming(); } else if (msg.event === 'teacher:stream-stopped' && msg.data.studentId == studentId) { setScreenFrame(null); setWebcamFrame(null); setStreaming(false); setErrorMessage('Sinh viên đã dừng stream'); } } catch (err) { console.error('Error parsing WS frame:', err); } }; ws.onerror = () => {}; ws.onclose = () => { clearPing(); if (intentionalClose.current) return; setStreaming(false); if (!hasOpened.current && !hasFrames.current) { setErrorMessage('Không thể kết nối máy chủ giám sát'); } else { setErrorMessage('Mất kết nối giám sát — đang thử lại...'); } const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000); retryTimer = setTimeout(connect, delay); }; }; connect(); return () => { intentionalClose.current = true; clearPing(); if (retryTimer) clearTimeout(retryTimer); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId } })); } wsRef.current?.close(); wsRef.current = null; }; }, [studentId]); useEffect(() => { const onFsChange = () => { setIsFullscreen(document.fullscreenElement === screenPanelRef.current); }; document.addEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange); }, []); const toggleFullscreen = useCallback(async () => { const el = screenPanelRef.current; if (!el) return; try { if (document.fullscreenElement === el) { await document.exitFullscreen(); } else { await el.requestFullscreen(); } } catch (e) { console.error('Fullscreen error', e); } }, []); const zoomIn = (target: 'screen' | 'webcam') => { const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx; setter(i => Math.min(i + 1, ZOOM_STEPS.length - 1)); }; const zoomOut = (target: 'screen' | 'webcam') => { const setter = target === 'screen' ? setScreenZoomIdx : setWebcamZoomIdx; setter(i => Math.max(i - 1, 0)); }; const zoomReset = (target: 'screen' | 'webcam') => { if (target === 'screen') setScreenZoomIdx(2); else setWebcamZoomIdx(2); }; const renderZoomToolbar = (target: 'screen' | 'webcam', zoom: number, onFs?: () => void) => (
{Math.round(zoom * 100)}% {onFs && ( )}
); return (
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
{errorMessage && !screenFrame && !webcamFrame && (
{errorMessage}
)}
Màn hình sinh viên {renderZoomToolbar('screen', screenZoom, toggleFullscreen)}
{screenFrame ? ( Màn hình sinh viên ) : (

Đang chờ màn hình...

)}
{showWebcam && (
Webcam {renderZoomToolbar('webcam', webcamZoom)}
{webcamFrame ? ( Webcam sinh viên ) : (

Đang chờ webcam...

)}
)}
); };