All checks were successful
Deploy on Master Change / deploy (push) Successful in 1m25s
258 lines
9.0 KiB
TypeScript
258 lines
9.0 KiB
TypeScript
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<ProctorStreamPanelsProps> = ({
|
||
studentId,
|
||
layout = 'focus',
|
||
}) => {
|
||
const [screenFrame, setScreenFrame] = useState<string | null>(null);
|
||
const [webcamFrame, setWebcamFrame] = useState<string | null>(null);
|
||
const [streaming, setStreaming] = useState(false);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(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<WebSocket | null>(null);
|
||
const intentionalClose = useRef(false);
|
||
const hasOpened = useRef(false);
|
||
const hasFrames = useRef(false);
|
||
const screenPanelRef = useRef<HTMLDivElement>(null);
|
||
|
||
const screenZoom = ZOOM_STEPS[screenZoomIdx];
|
||
const webcamZoom = ZOOM_STEPS[webcamZoomIdx];
|
||
|
||
useEffect(() => {
|
||
const wsUrl = getWsUrl('/ws?role=teacher');
|
||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let pingTimer: ReturnType<typeof setInterval> | 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) => (
|
||
<div className="panel-toolbar">
|
||
<button type="button" className="proctor-tool-btn" title="Thu nhỏ" onClick={() => zoomOut(target)}>−</button>
|
||
<span className="proctor-zoom-label">{Math.round(zoom * 100)}%</span>
|
||
<button type="button" className="proctor-tool-btn" title="Phóng to" onClick={() => zoomIn(target)}>+</button>
|
||
<button type="button" className="proctor-tool-btn" title="Về 100%" onClick={() => zoomReset(target)}>1:1</button>
|
||
{onFs && (
|
||
<button type="button" className="proctor-tool-btn proctor-tool-btn-wide" title="Toàn màn hình" onClick={onFs}>
|
||
{isFullscreen ? 'Thu nhỏ' : 'Phóng to'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="proctor-stream-wrap">
|
||
<div className="proctor-stream-toolbar">
|
||
<span className={`status-pill ${streaming || screenFrame || webcamFrame ? 'connected' : 'connecting'}`}>
|
||
{streaming || screenFrame || webcamFrame ? '● Đang phát' : '○ Đang kết nối...'}
|
||
</span>
|
||
<div className="proctor-stream-actions">
|
||
<button
|
||
type="button"
|
||
className={`btn btn-secondary proctor-action-btn ${showWebcam ? '' : 'active'}`}
|
||
onClick={() => setShowWebcam(v => !v)}
|
||
>
|
||
{showWebcam ? 'Ẩn webcam' : 'Hiện webcam'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{errorMessage && !screenFrame && !webcamFrame && (
|
||
<div className="alert-error proctor-stream-error">{errorMessage}</div>
|
||
)}
|
||
|
||
<div className={`proctor-grid proctor-grid-${layout} ${!showWebcam ? 'proctor-grid--no-webcam' : ''}`}>
|
||
<div
|
||
className={`proctor-panel screen-panel ${isFullscreen ? 'screen-panel--fullscreen' : ''}`}
|
||
ref={screenPanelRef}
|
||
>
|
||
<div className="panel-title-row">
|
||
<span className="panel-title-text">Màn hình sinh viên</span>
|
||
{renderZoomToolbar('screen', screenZoom, toggleFullscreen)}
|
||
</div>
|
||
<div className="panel-body screen-body" onDoubleClick={toggleFullscreen} title="Double-click để phóng to">
|
||
<div className="proctor-zoom-viewport">
|
||
{screenFrame ? (
|
||
<img
|
||
src={screenFrame}
|
||
alt="Màn hình sinh viên"
|
||
className="live-frame screen-img"
|
||
style={{ transform: `scale(${screenZoom})` }}
|
||
draggable={false}
|
||
/>
|
||
) : (
|
||
<div className="no-stream-placeholder"><p>Đang chờ màn hình...</p></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{showWebcam && (
|
||
<div className="proctor-panel webcam-panel">
|
||
<div className="panel-title-row">
|
||
<span className="panel-title-text">Webcam</span>
|
||
{renderZoomToolbar('webcam', webcamZoom)}
|
||
</div>
|
||
<div className="panel-body webcam-body">
|
||
<div className="proctor-zoom-viewport proctor-zoom-viewport--webcam">
|
||
{webcamFrame ? (
|
||
<img
|
||
src={webcamFrame}
|
||
alt="Webcam sinh viên"
|
||
className="live-frame webcam-img"
|
||
style={{ transform: `scale(${webcamZoom})` }}
|
||
draggable={false}
|
||
/>
|
||
) : (
|
||
<div className="no-stream-placeholder"><p>Đang chờ webcam...</p></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|