Files
rikkei_simple_care/management/src/components/ProctorStreamPanels.tsx
2026-07-13 15:52:58 +07:00

258 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
};