This commit is contained in:
@@ -142,8 +142,10 @@ type App struct {
|
|||||||
unsyncedOff int
|
unsyncedOff int
|
||||||
lastSyncTime time.Time
|
lastSyncTime time.Time
|
||||||
isStreamingSc bool
|
isStreamingSc bool
|
||||||
|
scIntervalMs int
|
||||||
streamScStop chan struct{}
|
streamScStop chan struct{}
|
||||||
isStreamingCam bool
|
isStreamingCam bool
|
||||||
|
camIntervalMs int
|
||||||
streamCamStop chan struct{}
|
streamCamStop chan struct{}
|
||||||
statusMsg string
|
statusMsg string
|
||||||
expectingLogin bool
|
expectingLogin bool
|
||||||
@@ -229,8 +231,16 @@ func NewApp() *App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) forceLightTheme() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtime.WindowSetLightTheme(a.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
a.forceLightTheme()
|
||||||
a.loadSession()
|
a.loadSession()
|
||||||
a.loadStats()
|
a.loadStats()
|
||||||
|
|
||||||
@@ -644,6 +654,7 @@ func (a *App) startLocalServer() {
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.localBrowserActive = true
|
a.localBrowserActive = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
a.forceLightTheme()
|
||||||
go func() {
|
go func() {
|
||||||
guard.SuppressFor(3 * time.Second)
|
guard.SuppressFor(3 * time.Second)
|
||||||
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", raw))
|
runtime.WindowExecJS(a.ctx, fmt.Sprintf("window.location.href = %q", raw))
|
||||||
@@ -662,8 +673,10 @@ const examViewHTML = `<!DOCTYPE html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
<title>%s</title>
|
<title>%s</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root,html{color-scheme:light only}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;height:100vh;display:flex;flex-direction:column}
|
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;height:100vh;display:flex;flex-direction:column}
|
||||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-shrink:0}
|
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-shrink:0}
|
||||||
@@ -741,8 +754,10 @@ const localBrowserHTML = `<!DOCTYPE html>
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
<title>Trình duyệt Local — Simple Care</title>
|
<title>Trình duyệt Local — Simple Care</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root,html{color-scheme:light only}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;min-height:100vh;display:flex;flex-direction:column}
|
body{font-family:Segoe UI,system-ui,sans-serif;background:#f5f7fa;color:#1a2332;min-height:100vh;display:flex;flex-direction:column}
|
||||||
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-wrap:wrap}
|
.bar{display:flex;align-items:center;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #e4e9f0;flex-wrap:wrap}
|
||||||
@@ -1640,11 +1655,23 @@ func (a *App) connectWS() {
|
|||||||
|
|
||||||
switch msg.Event {
|
switch msg.Event {
|
||||||
case "start_screenshot_stream":
|
case "start_screenshot_stream":
|
||||||
a.startScreenshotStream()
|
var intervalMs int
|
||||||
|
if val, ok := msg.Data["interval"]; ok {
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
intervalMs = int(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.startScreenshotStream(intervalMs)
|
||||||
case "stop_screenshot_stream":
|
case "stop_screenshot_stream":
|
||||||
a.stopScreenshotStream()
|
a.stopScreenshotStream()
|
||||||
case "start_webcam_stream":
|
case "start_webcam_stream":
|
||||||
a.startWebcamStream()
|
var intervalMs int
|
||||||
|
if val, ok := msg.Data["interval"]; ok {
|
||||||
|
if f, ok := val.(float64); ok {
|
||||||
|
intervalMs = int(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.startWebcamStream(intervalMs)
|
||||||
case "stop_webcam_stream":
|
case "stop_webcam_stream":
|
||||||
a.stopWebcamStream()
|
a.stopWebcamStream()
|
||||||
case "chat:message":
|
case "chat:message":
|
||||||
@@ -1707,18 +1734,28 @@ func (a *App) disconnectWS(expectedConn ...*websocket.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startScreenshotStream() {
|
func (a *App) startScreenshotStream(intervalMs int) {
|
||||||
|
if intervalMs <= 0 {
|
||||||
|
intervalMs = 3000
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if a.isStreamingSc {
|
if a.isStreamingSc {
|
||||||
|
if a.scIntervalMs != intervalMs {
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.stopScreenshotStream()
|
||||||
|
a.mu.Lock()
|
||||||
|
} else {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
a.isStreamingSc = true
|
a.isStreamingSc = true
|
||||||
|
a.scIntervalMs = intervalMs
|
||||||
a.streamScStop = make(chan struct{})
|
a.streamScStop = make(chan struct{})
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(250 * time.Millisecond)
|
ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -1740,7 +1777,7 @@ func (a *App) startScreenshotStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("[WS] Screenshot screen-streaming started.")
|
log.Printf("[WS] Screenshot screen-streaming started with interval %dms.", intervalMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) stopScreenshotStream() {
|
func (a *App) stopScreenshotStream() {
|
||||||
@@ -1751,23 +1788,34 @@ func (a *App) stopScreenshotStream() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.isStreamingSc = false
|
a.isStreamingSc = false
|
||||||
|
a.scIntervalMs = 0
|
||||||
close(a.streamScStop)
|
close(a.streamScStop)
|
||||||
log.Println("[WS] Screenshot screen-streaming stopped.")
|
log.Println("[WS] Screenshot screen-streaming stopped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startWebcamStream() {
|
func (a *App) startWebcamStream(intervalMs int) {
|
||||||
|
if intervalMs <= 0 {
|
||||||
|
intervalMs = 3000
|
||||||
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
if a.isStreamingCam {
|
if a.isStreamingCam {
|
||||||
|
if a.camIntervalMs != intervalMs {
|
||||||
|
a.mu.Unlock()
|
||||||
|
a.stopWebcamStream()
|
||||||
|
a.mu.Lock()
|
||||||
|
} else {
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
a.isStreamingCam = true
|
a.isStreamingCam = true
|
||||||
|
a.camIntervalMs = intervalMs
|
||||||
a.streamCamStop = make(chan struct{})
|
a.streamCamStop = make(chan struct{})
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
if !camera.IsNative {
|
if !camera.IsNative {
|
||||||
log.Println("[WS] Platform is non-darwin, delegating webcam stream to frontend events")
|
log.Printf("[WS] Platform is non-darwin, delegating webcam stream to frontend events with interval %dms", intervalMs)
|
||||||
runtime.EventsEmit(a.ctx, "start_webcam_stream")
|
runtime.EventsEmit(a.ctx, "start_webcam_stream", intervalMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1780,7 +1828,7 @@ func (a *App) startWebcamStream() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(250 * time.Millisecond)
|
ticker := time.NewTicker(time.Duration(intervalMs) * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
emptyCount := 0
|
emptyCount := 0
|
||||||
sentCount := 0
|
sentCount := 0
|
||||||
@@ -1813,7 +1861,7 @@ func (a *App) startWebcamStream() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("[WS] Webcam native streaming started.")
|
log.Printf("[WS] Webcam native streaming started with interval %dms.", intervalMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) stopWebcamStream() {
|
func (a *App) stopWebcamStream() {
|
||||||
@@ -1824,6 +1872,7 @@ func (a *App) stopWebcamStream() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.isStreamingCam = false
|
a.isStreamingCam = false
|
||||||
|
a.camIntervalMs = 0
|
||||||
if a.streamCamStop != nil {
|
if a.streamCamStop != nil {
|
||||||
close(a.streamCamStop)
|
close(a.streamCamStop)
|
||||||
}
|
}
|
||||||
@@ -1980,6 +2029,7 @@ func (a *App) examStudentContext() (int64, *StudentExamSnapshot, error) {
|
|||||||
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
|
// ReturnToDashboard quay lại giao diện chính của app (giữ cookie WebView2).
|
||||||
func (a *App) ReturnToDashboard() {
|
func (a *App) ReturnToDashboard() {
|
||||||
a.setLocalBrowserActive(false)
|
a.setLocalBrowserActive(false)
|
||||||
|
a.forceLightTheme()
|
||||||
guard.SuppressFor(5 * time.Second)
|
guard.SuppressFor(5 * time.Second)
|
||||||
runtime.WindowReloadApp(a.ctx)
|
runtime.WindowReloadApp(a.ctx)
|
||||||
}
|
}
|
||||||
@@ -2057,6 +2107,7 @@ func (a *App) OpenGoogleTranslate() {
|
|||||||
// OpenLocalBrowser mở trình duyệt chỉ cho phép localhost (test bài làm local).
|
// OpenLocalBrowser mở trình duyệt chỉ cho phép localhost (test bài làm local).
|
||||||
func (a *App) OpenLocalBrowser() {
|
func (a *App) OpenLocalBrowser() {
|
||||||
a.setLocalBrowserActive(true)
|
a.setLocalBrowserActive(true)
|
||||||
|
a.forceLightTheme()
|
||||||
guard.SuppressFor(5 * time.Second)
|
guard.SuppressFor(5 * time.Second)
|
||||||
runtime.WindowExecJS(a.ctx, `window.location.href = 'http://127.0.0.1:34115/local-browser'`)
|
runtime.WindowExecJS(a.ctx, `window.location.href = 'http://127.0.0.1:34115/local-browser'`)
|
||||||
}
|
}
|
||||||
@@ -2142,6 +2193,7 @@ func (a *App) openExamWebView(targetURL, title string) error {
|
|||||||
return errors.New("không có nội dung để mở")
|
return errors.New("không có nội dung để mở")
|
||||||
}
|
}
|
||||||
a.setLocalBrowserActive(false)
|
a.setLocalBrowserActive(false)
|
||||||
|
a.forceLightTheme()
|
||||||
// URL local (PDF/resource): khung exam-view. URL ngoài (quiz): mở top-level giữ first-party cookies/session.
|
// URL local (PDF/resource): khung exam-view. URL ngoài (quiz): mở top-level giữ first-party cookies/session.
|
||||||
if isLocalExamURL(targetURL) {
|
if isLocalExamURL(targetURL) {
|
||||||
wrapper := fmt.Sprintf(
|
wrapper := fmt.Sprintf(
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<meta name="color-scheme" content="light"/>
|
||||||
<link rel="icon" type="image/jpeg" href="/src/assets/logo.jpeg"/>
|
<link rel="icon" type="image/jpeg" href="/src/assets/logo.jpeg"/>
|
||||||
<title>Simple Care — Rikkei Education</title>
|
<title>Simple Care — Rikkei Education</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light only;
|
||||||
--bg-main: #f5f7fa;
|
--bg-main: #f5f7fa;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-subtle: #f0f3f7;
|
--bg-subtle: #f0f3f7;
|
||||||
@@ -36,6 +37,7 @@ html {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
color-scheme: light only;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ let bannerStaffId = 0;
|
|||||||
// Quản lý webcam
|
// Quản lý webcam
|
||||||
let webcamStream = null;
|
let webcamStream = null;
|
||||||
let webcamInterval = null;
|
let webcamInterval = null;
|
||||||
|
let currentWebcamIntervalMs = null;
|
||||||
const webcamVideo = document.createElement('video');
|
const webcamVideo = document.createElement('video');
|
||||||
webcamVideo.autoplay = true;
|
webcamVideo.autoplay = true;
|
||||||
webcamVideo.playsInline = true;
|
webcamVideo.playsInline = true;
|
||||||
@@ -884,8 +885,17 @@ function startStatsTicker() {
|
|||||||
setInterval(pullStats, 1000);
|
setInterval(pullStats, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWebcam() {
|
async function startWebcam(intervalMs) {
|
||||||
if (webcamStream) return;
|
if (!intervalMs || intervalMs <= 0) {
|
||||||
|
intervalMs = 3000;
|
||||||
|
}
|
||||||
|
if (webcamStream) {
|
||||||
|
if (currentWebcamIntervalMs === intervalMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopWebcam();
|
||||||
|
}
|
||||||
|
currentWebcamIntervalMs = intervalMs;
|
||||||
try {
|
try {
|
||||||
webcamStream = await navigator.mediaDevices.getUserMedia({
|
webcamStream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: { width: 320, height: 240, frameRate: { max: 10 } }
|
video: { width: 320, height: 240, frameRate: { max: 10 } }
|
||||||
@@ -898,7 +908,7 @@ async function startWebcam() {
|
|||||||
const dataUrl = webcamCanvas.toDataURL('image/jpeg', 0.4);
|
const dataUrl = webcamCanvas.toDataURL('image/jpeg', 0.4);
|
||||||
window.go.main.App.SendWebcamFrame(dataUrl);
|
window.go.main.App.SendWebcamFrame(dataUrl);
|
||||||
}
|
}
|
||||||
}, 250);
|
}, intervalMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to open webcam:', err);
|
console.error('Failed to open webcam:', err);
|
||||||
}
|
}
|
||||||
@@ -909,6 +919,7 @@ function stopWebcam() {
|
|||||||
clearInterval(webcamInterval);
|
clearInterval(webcamInterval);
|
||||||
webcamInterval = null;
|
webcamInterval = null;
|
||||||
}
|
}
|
||||||
|
currentWebcamIntervalMs = null;
|
||||||
if (webcamStream) {
|
if (webcamStream) {
|
||||||
webcamStream.getTracks().forEach(track => track.stop());
|
webcamStream.getTracks().forEach(track => track.stop());
|
||||||
webcamStream = null;
|
webcamStream = null;
|
||||||
|
|||||||
@@ -69,13 +69,14 @@ func main() {
|
|||||||
AssetServer: &assetserver.Options{
|
AssetServer: &assetserver.Options{
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
},
|
},
|
||||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
BackgroundColour: &options.RGBA{R: 245, G: 247, B: 250, A: 1},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
OnBeforeClose: func(ctx context.Context) (prevent bool) {
|
||||||
return app.HandleBeforeClose()
|
return app.HandleBeforeClose()
|
||||||
},
|
},
|
||||||
Windows: &windows.Options{
|
Windows: &windows.Options{
|
||||||
WebviewUserDataPath: app.webviewDataPath,
|
WebviewUserDataPath: app.webviewDataPath,
|
||||||
|
Theme: windows.Light,
|
||||||
},
|
},
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const ExamGridProctor: React.FC<ExamGridProctorProps> = ({
|
|||||||
});
|
});
|
||||||
onlineIds.forEach((id) => {
|
onlineIds.forEach((id) => {
|
||||||
if (!prev.has(id)) {
|
if (!prev.has(id)) {
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id } }));
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId: id, mode: 'grid' } }));
|
||||||
prev.add(id);
|
prev.add(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const ProctorStreamPanels: React.FC<ProctorStreamPanelsProps> = ({
|
|||||||
hasOpened.current = true;
|
hasOpened.current = true;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId } }));
|
ws.send(JSON.stringify({ event: 'teacher:subscribe', data: { studentId, mode: 'focus' } }));
|
||||||
clearPing();
|
clearPing();
|
||||||
pingTimer = setInterval(() => {
|
pingTimer = setInterval(() => {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
|||||||
@@ -57,18 +57,20 @@ type WsHub struct {
|
|||||||
students map[int64]*SocketClient
|
students map[int64]*SocketClient
|
||||||
teachers map[string]*SocketClient
|
teachers map[string]*SocketClient
|
||||||
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
teachersByStaff map[uint][]string // staffId -> teacher connection addresses
|
||||||
subscribers map[int64][]string // studentId -> list of teacher connection addresses
|
subscribers map[int64]map[string]string // studentId -> teacher connection address -> mode ("grid" | "focus")
|
||||||
grace map[int64]offlineGrace
|
grace map[int64]offlineGrace
|
||||||
graceTimers map[int64]*time.Timer
|
graceTimers map[int64]*time.Timer
|
||||||
|
lastRelay map[string]time.Time // studentID:event -> last relay time
|
||||||
}
|
}
|
||||||
|
|
||||||
var Hub = &WsHub{
|
var Hub = &WsHub{
|
||||||
students: make(map[int64]*SocketClient),
|
students: make(map[int64]*SocketClient),
|
||||||
teachers: make(map[string]*SocketClient),
|
teachers: make(map[string]*SocketClient),
|
||||||
teachersByStaff: make(map[uint][]string),
|
teachersByStaff: make(map[uint][]string),
|
||||||
subscribers: make(map[int64][]string),
|
subscribers: make(map[int64]map[string]string),
|
||||||
grace: make(map[int64]offlineGrace),
|
grace: make(map[int64]offlineGrace),
|
||||||
graceTimers: make(map[int64]*time.Timer),
|
graceTimers: make(map[int64]*time.Timer),
|
||||||
|
lastRelay: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
func (h *WsHub) IsStudentOnline(studentRkID int64) bool {
|
||||||
@@ -171,7 +173,7 @@ func (h *WsHub) ForceStudentOffline(studentID int64) {
|
|||||||
log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID)
|
log.Printf("[WS] Student %d force offline (intentional quit/violation)", studentID)
|
||||||
h.broadcastPresence(studentID, false, classID)
|
h.broadcastPresence(studentID, false, classID)
|
||||||
if teachers, exists := h.subscribers[studentID]; exists {
|
if teachers, exists := h.subscribers[studentID]; exists {
|
||||||
for _, tAddr := range teachers {
|
for tAddr := range teachers {
|
||||||
if t, found := h.teachers[tAddr]; found {
|
if t, found := h.teachers[tAddr]; found {
|
||||||
_ = t.WriteJSON(SocketMsg{
|
_ = t.WriteJSON(SocketMsg{
|
||||||
Event: "teacher:stream-stopped",
|
Event: "teacher:stream-stopped",
|
||||||
@@ -207,7 +209,7 @@ func (h *WsHub) finalizeStudentOffline(studentID int64, classID int64) {
|
|||||||
h.broadcastPresence(studentID, false, classID)
|
h.broadcastPresence(studentID, false, classID)
|
||||||
|
|
||||||
if teachers, exists := h.subscribers[studentID]; exists {
|
if teachers, exists := h.subscribers[studentID]; exists {
|
||||||
for _, tAddr := range teachers {
|
for tAddr := range teachers {
|
||||||
if t, found := h.teachers[tAddr]; found {
|
if t, found := h.teachers[tAddr]; found {
|
||||||
_ = t.WriteJSON(SocketMsg{
|
_ = t.WriteJSON(SocketMsg{
|
||||||
Event: "teacher:stream-stopped",
|
Event: "teacher:stream-stopped",
|
||||||
@@ -256,10 +258,8 @@ func (h *WsHub) Register(c *SocketClient) {
|
|||||||
h.students[c.StudentID] = c
|
h.students[c.StudentID] = c
|
||||||
log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace)
|
log.Printf("[WS] Student %d registered (Address: %s, Class: %d, reconnectGrace=%v)", c.StudentID, c.Addr, c.ClassID, wasInGrace)
|
||||||
h.broadcastPresence(c.StudentID, true, c.ClassID)
|
h.broadcastPresence(c.StudentID, true, c.ClassID)
|
||||||
if subs, exists := h.subscribers[c.StudentID]; exists && len(subs) > 0 {
|
if _, exists := h.subscribers[c.StudentID]; exists {
|
||||||
_ = c.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
h.updateStudentStreamModeLocked(c.StudentID)
|
||||||
_ = c.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
|
||||||
log.Printf("[WS] Student %d has active subscribers. Sent start stream commands.", c.StudentID)
|
|
||||||
}
|
}
|
||||||
} else if c.Role == "teacher" {
|
} else if c.Role == "teacher" {
|
||||||
h.teachers[c.Addr] = c
|
h.teachers[c.Addr] = c
|
||||||
@@ -310,66 +310,88 @@ func (h *WsHub) Unregister(c *SocketClient) {
|
|||||||
}
|
}
|
||||||
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
log.Printf("[WS] Teacher %s disconnected", c.Addr)
|
||||||
|
|
||||||
for sID, teachersList := range h.subscribers {
|
for sID, teachersMap := range h.subscribers {
|
||||||
newList := []string{}
|
delete(teachersMap, c.Addr)
|
||||||
for _, addr := range teachersList {
|
if len(teachersMap) == 0 {
|
||||||
if addr != c.Addr {
|
|
||||||
newList = append(newList, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(newList) == 0 {
|
|
||||||
delete(h.subscribers, sID)
|
delete(h.subscribers, sID)
|
||||||
if student, exists := h.students[sID]; exists {
|
if student, exists := h.students[sID]; exists {
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
h.subscribers[sID] = newList
|
h.updateStudentStreamModeLocked(sID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) Subscribe(teacherAddr string, studentID int64) {
|
func (h *WsHub) updateStudentStreamModeLocked(studentID int64) {
|
||||||
h.mu.Lock()
|
student, exists := h.students[studentID]
|
||||||
defer h.mu.Unlock()
|
if !exists || student == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
teachersList := h.subscribers[studentID]
|
teachersMap, ok := h.subscribers[studentID]
|
||||||
alreadySubscribed := false
|
if !ok || len(teachersMap) == 0 {
|
||||||
for _, addr := range teachersList {
|
_ = student.WriteJSON(SocketMsg{Event: "stop_screenshot_stream"})
|
||||||
if addr == teacherAddr {
|
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||||
alreadySubscribed = true
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine effective mode: if any teacher wants "focus", use focus. Otherwise "grid".
|
||||||
|
effectiveMode := "grid"
|
||||||
|
for _, mode := range teachersMap {
|
||||||
|
if mode == "focus" {
|
||||||
|
effectiveMode = "focus"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !alreadySubscribed {
|
|
||||||
h.subscribers[studentID] = append(teachersList, teacherAddr)
|
intervalMs := 3000 // default for grid
|
||||||
log.Printf("[WS] Teacher %s subscribed to student %d stream", teacherAddr, studentID)
|
if effectiveMode == "focus" {
|
||||||
|
intervalMs = 500 // 500ms for focus view
|
||||||
}
|
}
|
||||||
|
|
||||||
if student, exists := h.students[studentID]; exists {
|
_ = student.WriteJSON(SocketMsg{
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "start_screenshot_stream"})
|
Event: "start_screenshot_stream",
|
||||||
_ = student.WriteJSON(SocketMsg{Event: "start_webcam_stream"})
|
Data: map[string]any{"interval": intervalMs},
|
||||||
|
})
|
||||||
|
_ = student.WriteJSON(SocketMsg{
|
||||||
|
Event: "start_webcam_stream",
|
||||||
|
Data: map[string]any{"interval": intervalMs},
|
||||||
|
})
|
||||||
|
log.Printf("[WS] Updated student %d stream mode to %s (interval: %dms)", studentID, effectiveMode, intervalMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WsHub) Subscribe(teacherAddr string, studentID int64, mode string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
teachersMap, exists := h.subscribers[studentID]
|
||||||
|
if !exists {
|
||||||
|
teachersMap = make(map[string]string)
|
||||||
|
h.subscribers[studentID] = teachersMap
|
||||||
|
}
|
||||||
|
|
||||||
|
teachersMap[teacherAddr] = mode
|
||||||
|
log.Printf("[WS] Teacher %s subscribed to student %d stream in %s mode", teacherAddr, studentID, mode)
|
||||||
|
|
||||||
|
h.updateStudentStreamModeLocked(studentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
teachersList, exists := h.subscribers[studentID]
|
teachersMap, exists := h.subscribers[studentID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newList := []string{}
|
delete(teachersMap, teacherAddr)
|
||||||
for _, addr := range teachersList {
|
log.Printf("[WS] Teacher %s unsubscribed from student %d stream", teacherAddr, studentID)
|
||||||
if addr != teacherAddr {
|
|
||||||
newList = append(newList, addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(newList) == 0 {
|
if len(teachersMap) == 0 {
|
||||||
delete(h.subscribers, studentID)
|
delete(h.subscribers, studentID)
|
||||||
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
|
log.Printf("[WS] Student %d has no more proctor subscribers. Stopping streams.", studentID)
|
||||||
if student, exists := h.students[studentID]; exists {
|
if student, exists := h.students[studentID]; exists {
|
||||||
@@ -377,20 +399,46 @@ func (h *WsHub) Unsubscribe(teacherAddr string, studentID int64) {
|
|||||||
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
_ = student.WriteJSON(SocketMsg{Event: "stop_webcam_stream"})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
h.subscribers[studentID] = newList
|
h.updateStudentStreamModeLocked(studentID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
func (h *WsHub) RelayFrame(studentID int64, event string, data map[string]any) {
|
||||||
h.mu.RLock()
|
h.mu.Lock()
|
||||||
teachersList, exists := h.subscribers[studentID]
|
teachersMap, exists := h.subscribers[studentID]
|
||||||
if !exists || len(teachersList) == 0 {
|
if !exists || len(teachersMap) == 0 {
|
||||||
h.mu.RUnlock()
|
h.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
addrs := make([]string, len(teachersList))
|
|
||||||
copy(addrs, teachersList)
|
// Compute effective mode/interval
|
||||||
h.mu.RUnlock()
|
effectiveMode := "grid"
|
||||||
|
for _, mode := range teachersMap {
|
||||||
|
if mode == "focus" {
|
||||||
|
effectiveMode = "focus"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minInterval := 3000 * time.Millisecond
|
||||||
|
if effectiveMode == "focus" {
|
||||||
|
minInterval = 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
relayKey := strconv.FormatInt(studentID, 10) + ":" + event
|
||||||
|
lastTime := h.lastRelay[relayKey]
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(lastTime) < minInterval {
|
||||||
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.lastRelay[relayKey] = now
|
||||||
|
|
||||||
|
addrs := make([]string, 0, len(teachersMap))
|
||||||
|
for addr := range teachersMap {
|
||||||
|
addrs = append(addrs, addr)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
relayEvent := "teacher:screenshot-stream-frame"
|
relayEvent := "teacher:screenshot-stream-frame"
|
||||||
if event == "webcam_stream_frame" {
|
if event == "webcam_stream_frame" {
|
||||||
@@ -519,8 +567,12 @@ func WebSocketHandler(db *gorm.DB) func(*websocket.Conn) {
|
|||||||
case string:
|
case string:
|
||||||
sID, _ = strconv.ParseInt(v, 10, 64)
|
sID, _ = strconv.ParseInt(v, 10, 64)
|
||||||
}
|
}
|
||||||
|
mode, _ := msg.Data["mode"].(string)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "focus"
|
||||||
|
}
|
||||||
if sID > 0 {
|
if sID > 0 {
|
||||||
Hub.Subscribe(client.Addr, sID)
|
Hub.Subscribe(client.Addr, sID, mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user