feat: WebSocket real-time + rAF render gate + guest board + screen picker

Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+168
View File
@@ -0,0 +1,168 @@
/**
* Public (no-auth) guest classroom routes.
* Guests access the whiteboard read-only via a token in the URL.
*
* GET /api/classroom/guest/:token — session info (pre-join)
* POST /api/classroom/guest/:token/join — choose display name, get guestId
* GET /api/classroom/guest/:token/strokes — strokes for ?page_num=N
* GET /api/classroom/guest/:token/stream — SSE stream (?guestId=X)
* POST /api/classroom/guest/:token/leave — notify departure (optional)
*/
const router = require('express').Router();
const crypto = require('crypto');
const db = require('../db/db');
const { addGuestClient, removeGuestClient, emitToGuests, emit } = require('../sse');
/* ── In-memory guest registry (cleared on server restart — fine for guests) */
const guests = new Map(); // guestId → { name, sessionId, connectedAt }
/* Helper: look up session by token (active only) */
function sessionByToken(token) {
return db.prepare(
"SELECT id, title, status, current_page, teacher_id FROM classroom_sessions WHERE guest_token=?"
).get(token);
}
/* Helper: emit guest-joined/left to the real participants of the session */
function notifySession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return;
const { emit: _emit, emitToClass } = require('../sse');
if (session.class_id) {
emitToClass(session.class_id, data);
_emit(session.teacher_id, data);
} else {
_emit(session.teacher_id, data);
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const { user_id } of invites) _emit(user_id, data);
}
}
/* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */
router.get('/:token', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
const pageCount = db.prepare(
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
).get(session.id).c || 1;
res.json({
id: session.id,
title: session.title || 'Онлайн-урок',
status: session.status,
current_page: session.current_page || 1,
page_count: pageCount,
});
});
/* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */
router.post('/:token/join', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
if (session.status !== 'active')
return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' });
const rawName = (req.body?.name || '').trim().slice(0, 40);
const name = rawName || 'Гость';
const guestId = 'g_' + crypto.randomBytes(12).toString('base64url');
guests.set(guestId, { name, sessionId: session.id, connectedAt: Date.now() });
// Notify real participants that a guest joined
notifySession(session.id, {
type: 'classroom_guest_joined',
sessionId: session.id,
guestId,
guestName: name,
});
const pageCount = db.prepare(
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
).get(session.id).c || 1;
res.json({
guestId,
sessionId: session.id,
title: session.title || 'Онлайн-урок',
current_page: session.current_page || 1,
page_count: pageCount,
});
});
/* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */
router.get('/:token/strokes', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
if (session.status !== 'active')
return res.status(403).json({ error: 'Урок не активен' });
const pageNum = Math.max(1, Number(req.query.page_num) || 1);
const sinceSeq = Number(req.query.since_seq) || 0;
const strokes = sinceSeq > 0
? db.prepare(
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq'
).all(session.id, pageNum, sinceSeq)
: db.prepare(
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
).all(session.id, pageNum);
const pageRow = db.prepare(
'SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?'
).get(session.id, pageNum);
const parsed = strokes.map(s => ({ ...s, data: JSON.parse(s.data || '{}') }));
res.json({ strokes: parsed, template: pageRow?.template || 'blank', seq: parsed.at(-1)?.seq || 0 });
});
/* ── GET /api/classroom/guest/:token/stream ─── SSE */
router.get('/:token/stream', (req, res) => {
const session = sessionByToken(req.params.token);
if (!session) return res.status(404).end();
if (session.status !== 'active') return res.status(403).end();
const guestId = req.query.guestId;
const guest = guestId ? guests.get(guestId) : null;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable buffering
res.flushHeaders();
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
addGuestClient(session.id, res);
req.on('close', () => {
removeGuestClient(session.id, res);
if (guest) {
guests.delete(guestId);
notifySession(session.id, {
type: 'classroom_guest_left',
sessionId: session.id,
guestId,
guestName: guest.name,
});
}
});
});
/* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */
router.post('/:token/leave', (req, res) => {
const guestId = req.body?.guestId;
const guest = guestId ? guests.get(guestId) : null;
if (guest) {
guests.delete(guestId);
notifySession(guest.sessionId, {
type: 'classroom_guest_left',
sessionId: guest.sessionId,
guestId,
guestName: guest.name,
});
}
res.json({ ok: true });
});
module.exports = router;