import React, { useEffect, useState, useRef, useMemo } from 'react'; import { getWsUrl, type ExamRoomStudent } from '../api'; import { openStaffChat } from '../chatEvents'; interface ExamGridProctorProps { students: ExamRoomStudent[]; onlineIds: number[]; onSelectStudent: (student: ExamRoomStudent) => void; } export const ExamGridProctor: React.FC = ({ students, onlineIds, onSelectStudent, }) => { const [screenFrames, setScreenFrames] = useState>({}); const [webcamFrames, setWebcamFrames] = useState>({}); const [gridCols, setGridCols] = useState(3); const [showWebcamOverlay, setShowWebcamOverlay] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [onlyOnline, setOnlyOnline] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const wsRef = useRef(null); const subscribedRef = useRef>(new Set()); const gridContainerRef = useRef(null); const studentIdsString = useMemo( () => students.map((s) => s.studentRkId).join(','), [students] ); const onlineKey = useMemo(() => onlineIds.join(','), [onlineIds]); const onlineIdsRef = useRef(onlineIds); onlineIdsRef.current = onlineIds; const syncSubscriptions = (ws: WebSocket) => { if (ws.readyState !== WebSocket.OPEN) return; const currentOnlineIds = onlineIdsRef.current; const target = new Set(currentOnlineIds); const prev = subscribedRef.current; prev.forEach((id) => { if (!target.has(id)) { ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } })); prev.delete(id); } }); currentOnlineIds.forEach((id) => { if (!prev.has(id)) { ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } })); prev.add(id); } }); }; useEffect(() => { if (students.length === 0) return; const wsUrl = getWsUrl('/ws?role=teacher'); let closed = false; let retryTimer: ReturnType | null = null; let pingTimer: ReturnType | null = null; let attempt = 0; const clearPing = () => { if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } }; const connect = () => { if (closed) return; const ws = new WebSocket(wsUrl); wsRef.current = ws; subscribedRef.current = new Set(); ws.onopen = () => { attempt = 0; syncSubscriptions(ws); 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') { const { studentId, imageBuffer } = msg.data; setScreenFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); } else if (msg.event === 'teacher:webcam-stream-frame') { const { studentId, imageBuffer } = msg.data; setWebcamFrames((prev) => ({ ...prev, [studentId]: imageBuffer })); } else if (msg.event === 'teacher:stream-stopped') { const { studentId } = msg.data; setScreenFrames((prev) => { const next = { ...prev }; delete next[studentId]; return next; }); setWebcamFrames((prev) => { const next = { ...prev }; delete next[studentId]; return next; }); } } catch (err) { console.error('Error parsing WS message in grid view:', err); } }; ws.onclose = () => { clearPing(); subscribedRef.current = new Set(); if (!closed) { const delay = Math.min(1000 * 2 ** Math.min(attempt++, 4), 10000); retryTimer = setTimeout(connect, delay); } }; }; connect(); return () => { closed = true; clearPing(); if (retryTimer) clearTimeout(retryTimer); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { subscribedRef.current.forEach((id) => { ws.send(JSON.stringify({ event: 'teacher:unsubscribe', data: { studentId: id } })); }); } ws?.close(); wsRef.current = null; subscribedRef.current = new Set(); }; }, [studentIdsString]); useEffect(() => { const ws = wsRef.current; if (ws) syncSubscriptions(ws); }, [onlineKey]); // Clean up frames when students are removed or go offline useEffect(() => { setScreenFrames((prev) => { const next = { ...prev }; let changed = false; Object.keys(next).forEach((idStr) => { const id = Number(idStr); if (!onlineIds.includes(id)) { delete next[id]; changed = true; } }); return changed ? next : prev; }); setWebcamFrames((prev) => { const next = { ...prev }; let changed = false; Object.keys(next).forEach((idStr) => { const id = Number(idStr); if (!onlineIds.includes(id)) { delete next[id]; changed = true; } }); return changed ? next : prev; }); }, [onlineIds]); const filteredStudents = useMemo(() => { return students.filter((s) => { const matchesSearch = s.fullName.toLowerCase().includes(searchQuery.toLowerCase()) || s.studentCode.toLowerCase().includes(searchQuery.toLowerCase()); const isOnline = onlineIds.includes(s.studentRkId); const matchesOnlineFilter = !onlyOnline || isOnline; return matchesSearch && matchesOnlineFilter; }); }, [students, onlineIds, searchQuery, onlyOnline]); const handleOpenChat = (s: ExamRoomStudent, e: React.MouseEvent) => { e.stopPropagation(); openStaffChat({ studentRkId: s.studentRkId, fullName: s.fullName, studentCode: s.studentCode, email: s.email, }); }; useEffect(() => { const handleFsChange = () => { setIsFullscreen(document.fullscreenElement === gridContainerRef.current); }; document.addEventListener('fullscreenchange', handleFsChange); return () => document.removeEventListener('fullscreenchange', handleFsChange); }, []); const toggleFullscreen = () => { const el = gridContainerRef.current; if (!el) return; if (document.fullscreenElement === el) { document.exitFullscreen().catch(console.error); } else { el.requestFullscreen().catch(console.error); } }; return (
setSearchQuery(e.target.value)} /> 🔍
Cột:
{filteredStudents.map((s) => { const isOnline = onlineIds.includes(s.studentRkId); const screenFrame = screenFrames[s.studentRkId]; const webcamFrame = webcamFrames[s.studentRkId]; return (
onSelectStudent(s)} >
{s.fullName}
{s.studentCode}
{isOnline ? (
{screenFrame ? ( {`Màn ) : (
Đang kết nối màn hình...
)} {showWebcamOverlay && webcamFrame && (
{`Webcam
)}
) : (
📴 Ngoại tuyến (Offline)
)}
e.stopPropagation()}> {s.paperTitle ? `Đề: ${s.paperTitle}` : 'Chưa gán đề'}
); })} {filteredStudents.length === 0 && (
🔍

Không tìm thấy sinh viên nào.

)}
); };