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
+27 -5
View File
@@ -20,12 +20,14 @@
* rtc.destroy();
*/
class ClassroomRTC {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
this._sid = sessionId;
this._uid = userId;
this._onSignal = onSignal; // fn(targetUid, payload)
this._onScreen = onScreenStream; // fn(stream | null)
this._onMicActive = onMicActive; // fn(uid, bool) — optional
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
this._vadThreshold = vadThreshold ?? 12;
this._peers = new Map(); // uid → PeerState
this._localStream = null; // mic audio
@@ -62,15 +64,17 @@ class ClassroomRTC {
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
let speaking = false;
const THRESHOLD = 12;
const THRESHOLD = this._vadThreshold;
const timer = setInterval(() => {
analyser.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
const level = Math.min(100, Math.round((avg / 64) * 100));
const now = avg > THRESHOLD;
if (now !== speaking) {
speaking = now;
try { this._onMicActive(uid, speaking); } catch {}
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
}
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
}, 120);
this._vadTimers.set(uid, { ctx, timer });
} catch {}
@@ -307,10 +311,11 @@ class ClassroomRTC {
/* ── Screen sharing (teacher) ────────────────────────────────────────── */
async startScreenShare() {
async startScreenShare(constraints = {}) {
try {
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' }, audio: false,
video: { cursor: 'always', ...constraints.video },
audio: constraints.audio ?? false,
});
} catch { return null; }
@@ -340,6 +345,23 @@ class ClassroomRTC {
}
}
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
async useExistingScreenStream(stream) {
if (this._screenStream) {
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
}
this._screenStream = stream;
const vt = this._screenStream.getVideoTracks()[0];
if (!vt) return stream;
for (const [, peer] of this._peers) {
if (!peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
return stream;
}
isSharing() { return !!this._screenStream; }
/* ── Cleanup ─────────────────────────────────────────────────────────── */