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 ─────────────────────────────────────────────────────────── */
+1 -1
View File
@@ -792,7 +792,7 @@ class AngryBirdsSim {
/* Planet + g — second line, readable */
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
ctx.fillText(`${pl.label.replace(/<svg[\s\S]*?<\/svg>/g, '').trim()} g = ${pl.g} м/с²`, 14, 50);
/* Wind reminder */
if (lvl?.wind) {
+1
View File
@@ -50,6 +50,7 @@ class BohrAtomSim {
this.W = w; this.H = h;
}
getParams() { return { level: this.level }; }
setParams({ level } = {}) {
if (level !== undefined) {
const n = Math.max(1, Math.min(6, Math.round(+level)));
+16 -4
View File
@@ -1,4 +1,16 @@
'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
return '';
});
}
/* ════════════════════════════════════════════════════════════════
ChemSandboxSim v2 — «Химическая песочница»
• Колба Эрленмейера с реалистичным стеклом
@@ -1089,7 +1101,7 @@ class ChemSandboxSim {
// ── Молекулярное уравнение ──
ctx.font = 'bold 17px "JetBrains Mono", monospace';
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
ctx.fillText(rx.eq, W / 2, y);
ctx.fillText(_csClean(rx.eq), W / 2, y);
y += 22;
// ── Тип реакции + пояснение ──
@@ -1109,7 +1121,7 @@ class ChemSandboxSim {
if (rx.why) {
ctx.font = '13px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(rx.why, W / 2, y);
ctx.fillText(_csClean(rx.why), W / 2, y);
y += 17;
}
@@ -1117,7 +1129,7 @@ class ChemSandboxSim {
if (rx.ionFull) {
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(155,200,255,0.60)';
ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y);
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
y += 16;
}
@@ -1125,7 +1137,7 @@ class ChemSandboxSim {
if (rx.ionNet) {
ctx.font = 'bold 13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(123,245,164,0.75)';
ctx.fillText('Сокр.: ' + rx.ionNet, W / 2, y);
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
y += 16;
}
+1
View File
@@ -74,6 +74,7 @@ class CollisionSim {
this.c.height = r.height || 420;
}
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
+1
View File
@@ -86,6 +86,7 @@ class ElectrolysisSim {
this._initIons();
}
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
+1
View File
@@ -48,6 +48,7 @@ class EquilibriumSim {
this.reset();
}
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
let needReset = false;
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
+2 -2
View File
@@ -1787,7 +1787,7 @@ class ForceSandboxSim {
// Угловая скорость ω — фиолетовая метка справа от тела
if (hasOmg) {
const sym = b.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = b.omega > 0 ? '\u21BB' : '\u21BA';
const labX = b.x + halfW + 7;
const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15;
ctx.save();
@@ -1852,7 +1852,7 @@ class ForceSandboxSim {
ctx.fillStyle = '#EF476F';
ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12);
if (Math.abs(body.omega) > 0.05) {
const sym = body.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = body.omega > 0 ? '\u21BB' : '\u21BA';
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22);
}
+1
View File
@@ -46,6 +46,7 @@ class GraphTransformSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, k: this.k, b: this.b, c: this.c }; }
setParams({ a, k, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (k !== undefined) this.k = +k;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -56,6 +56,7 @@ class IsoprocessSim {
setProcess(p) { this.process = p; this.draw(); this._emit(); }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
+1
View File
@@ -84,6 +84,7 @@ class MirrorSim {
this.draw(); this._emit();
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
+1
View File
@@ -34,6 +34,7 @@ class NormalDistSim {
this.W = w; this.H = h;
}
getParams() { return { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
+3
View File
@@ -51,6 +51,9 @@ class PendulumSim {
this.W = w; this.H = h;
}
getParams() {
return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
}
setParams({ L, g, theta, damping } = {}) {
if (L !== undefined) this.L = +L;
if (g !== undefined) this.g = +g;
+1
View File
@@ -60,6 +60,7 @@ class ProbabilitySim {
this.W = w; this.H = h;
}
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
+5
View File
@@ -88,6 +88,11 @@ class ProjectileSim {
this._cw = w; this._ch = h;
}
getParams() {
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
bounce: this.bounce, restitution: this.restitution };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
+1
View File
@@ -43,6 +43,7 @@ class QuadraticSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, b: this.b, c: this.c }; }
setParams({ a, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (b !== undefined) this.b = +b;
+1
View File
@@ -42,6 +42,7 @@ class RefractionSim {
this.W = w; this.H = h;
}
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
+1
View File
@@ -39,6 +39,7 @@ class ThinLensSim {
this.W = w; this.H = h;
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
+1
View File
@@ -67,6 +67,7 @@ class TitrationSim {
/* ── Public API ─────────────────────────────────────────── */
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
+2 -2
View File
@@ -792,7 +792,7 @@ class TriangleSim {
// Formula
this._drawFormulaBox(ctx, this.W, this.H,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² ${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
'#fbbf24');
ctx.restore();
@@ -845,7 +845,7 @@ class TriangleSim {
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
const statusCol = isRight ? '#22d55e' : '#f59e0b';
const statusText = isRight
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)}${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
+4
View File
@@ -59,6 +59,10 @@ class WavesSim {
this._emit();
}
getParams() {
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
n: this._n, speed: this._speed, mode: this._mode };
}
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
+1523 -192
View File
File diff suppressed because it is too large Load Diff