fd29acbbdd
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>
381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
/**
|
|
* ClassroomRTC — WebRTC mesh manager for audio + screen sharing.
|
|
*
|
|
* Topology: full mesh (each peer ↔ each peer).
|
|
* Audio: Opus ~30 Kbit/s per connection.
|
|
* Screen: teacher → renegotiation adds video track to existing connections.
|
|
*
|
|
* Usage:
|
|
* const rtc = new ClassroomRTC({
|
|
* sessionId, userId,
|
|
* onSignal(targetId, payload) — POST /signal
|
|
* onScreenStream(stream|null) — remote screen arrived / stopped
|
|
* onMicActive(userId, active) — visual mic indicator
|
|
* });
|
|
* await rtc.startAudio();
|
|
* await rtc.connectTo([uid1, uid2]); // new joiner sends offers
|
|
* rtc.handleSignal(fromUid, payload); // called from SSE
|
|
* rtc.addPeerForScreenShare(uid); // if teacher, offer screen to new joiner
|
|
* rtc.removePeer(uid);
|
|
* rtc.destroy();
|
|
*/
|
|
class ClassroomRTC {
|
|
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
|
|
this._screenStream = null; // outbound screen (teacher)
|
|
this._muted = false;
|
|
|
|
this._vadTimers = new Map(); // uid → {ctx, timer} for VAD
|
|
}
|
|
|
|
/* ── Audio ───────────────────────────────────────────��───────────────── */
|
|
|
|
async startAudio() {
|
|
try {
|
|
this._localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
|
|
this._startVAD(this._uid, this._localStream);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/* ── Voice Activity Detection ────────────────────────────────────────── */
|
|
|
|
_startVAD(uid, stream) {
|
|
if (!this._onMicActive || !window.AudioContext) return;
|
|
this._stopVAD(uid);
|
|
try {
|
|
const ctx = new AudioContext();
|
|
const src = ctx.createMediaStreamSource(stream);
|
|
const analyser = ctx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
analyser.smoothingTimeConstant = 0.4;
|
|
src.connect(analyser);
|
|
const buf = new Uint8Array(analyser.frequencyBinCount);
|
|
let speaking = false;
|
|
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 { 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 {}
|
|
}
|
|
|
|
_stopVAD(uid) {
|
|
const vad = this._vadTimers.get(uid);
|
|
if (!vad) return;
|
|
clearInterval(vad.timer);
|
|
try { vad.ctx.close(); } catch {}
|
|
this._vadTimers.delete(uid);
|
|
}
|
|
|
|
_stopAllVAD() {
|
|
for (const uid of [...this._vadTimers.keys()]) this._stopVAD(uid);
|
|
}
|
|
|
|
stopAudio() {
|
|
this._stopVAD(this._uid);
|
|
if (!this._localStream) return;
|
|
this._localStream.getTracks().forEach(t => t.stop());
|
|
this._localStream = null;
|
|
}
|
|
|
|
toggleMute() {
|
|
this._muted = !this._muted;
|
|
if (this._localStream) {
|
|
this._localStream.getAudioTracks().forEach(t => { t.enabled = !this._muted; });
|
|
}
|
|
return this._muted;
|
|
}
|
|
|
|
isMuted() { return this._muted; }
|
|
|
|
forceMute() {
|
|
this._muted = true;
|
|
if (this._localStream) {
|
|
this._localStream.getAudioTracks().forEach(t => { t.enabled = false; });
|
|
}
|
|
}
|
|
|
|
/* ── Connections ─────────────────────────────────────────────────────── */
|
|
|
|
/** New joiner calls this to send offers to all existing participants. */
|
|
async connectTo(userIds) {
|
|
for (const uid of userIds) {
|
|
if (uid !== this._uid) await this._sendOffer(uid);
|
|
}
|
|
}
|
|
|
|
/** Called when a new peer joins (existing participants call this to handle
|
|
* teacher screen-share renegotiation if needed). */
|
|
async addPeerForScreenShare(uid) {
|
|
if (!this._screenStream) return;
|
|
// New person joined while we are sharing screen — renegotiate
|
|
const peer = this._getPeer(uid);
|
|
if (peer) {
|
|
const vt = this._screenStream.getVideoTracks()[0];
|
|
if (vt && !peer.screenSender) {
|
|
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
|
|
// onnegotiationneeded will fire and renegotiate automatically
|
|
}
|
|
}
|
|
}
|
|
|
|
removePeer(uid) {
|
|
const peer = this._peers.get(uid);
|
|
if (!peer) return;
|
|
this._stopVAD(uid);
|
|
if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
|
|
try { peer.pc.close(); } catch {}
|
|
this._peers.delete(uid);
|
|
}
|
|
|
|
/**
|
|
* Called after SSE reconnect: re-connect peers whose ICE connection failed.
|
|
* Peers that are still connected are left alone.
|
|
* @param {number[]} currentUserIds — list of user ids currently in session (excluding self)
|
|
*/
|
|
async recoverPeers(currentUserIds) {
|
|
// Close and remove peers that left during the SSE gap
|
|
for (const uid of [...this._peers.keys()]) {
|
|
if (!currentUserIds.includes(uid)) this.removePeer(uid);
|
|
}
|
|
|
|
// Re-offer peers with failed/closed ICE
|
|
for (const uid of currentUserIds) {
|
|
if (uid === this._uid) continue;
|
|
const peer = this._peers.get(uid);
|
|
if (!peer) {
|
|
// Completely new peer (joined while SSE was down) — we act as initiator
|
|
await this._sendOffer(uid);
|
|
} else {
|
|
const state = peer.pc.iceConnectionState;
|
|
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
|
|
this.removePeer(uid);
|
|
await this._sendOffer(uid);
|
|
}
|
|
// else: 'connected' or 'completed' → leave intact
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── Signaling ───────────────────────────────────────────────────────── */
|
|
|
|
async handleSignal(fromUid, payload) {
|
|
const { kind } = payload;
|
|
try {
|
|
if (kind === 'offer') await this._handleOffer(fromUid, payload);
|
|
else if (kind === 'answer') await this._handleAnswer(fromUid, payload);
|
|
else if (kind === 'candidate') await this._handleCandidate(fromUid, payload);
|
|
} catch (e) {
|
|
console.warn('[RTC] handleSignal error', kind, e);
|
|
}
|
|
}
|
|
|
|
async _handleOffer(fromUid, payload) {
|
|
const peer = this._getOrCreate(fromUid);
|
|
await peer.pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp });
|
|
peer.remoteSet = true;
|
|
await this._flushCandidates(peer);
|
|
|
|
// Add own audio track if not already present
|
|
if (this._localStream) {
|
|
const existing = peer.pc.getSenders().map(s => s.track);
|
|
this._localStream.getAudioTracks().forEach(t => {
|
|
if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
|
|
});
|
|
}
|
|
|
|
const answer = await peer.pc.createAnswer();
|
|
await peer.pc.setLocalDescription(answer);
|
|
this._onSignal(fromUid, { kind: 'answer', sdp: answer.sdp });
|
|
}
|
|
|
|
async _handleAnswer(fromUid, payload) {
|
|
const peer = this._peers.get(fromUid);
|
|
if (!peer) return;
|
|
await peer.pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp });
|
|
peer.remoteSet = true;
|
|
await this._flushCandidates(peer);
|
|
}
|
|
|
|
async _handleCandidate(fromUid, payload) {
|
|
const peer = this._peers.get(fromUid);
|
|
if (!peer) return;
|
|
if (peer.remoteSet) {
|
|
try { await peer.pc.addIceCandidate(payload.candidate); } catch {}
|
|
} else {
|
|
peer.pendingCandidates.push(payload.candidate);
|
|
}
|
|
}
|
|
|
|
async _flushCandidates(peer) {
|
|
for (const c of peer.pendingCandidates) {
|
|
try { await peer.pc.addIceCandidate(c); } catch {}
|
|
}
|
|
peer.pendingCandidates = [];
|
|
}
|
|
|
|
async _sendOffer(targetUid) {
|
|
const peer = this._getOrCreate(targetUid);
|
|
if (this._localStream) {
|
|
const existing = peer.pc.getSenders().map(s => s.track);
|
|
this._localStream.getAudioTracks().forEach(t => {
|
|
if (!existing.includes(t)) peer.pc.addTrack(t, this._localStream);
|
|
});
|
|
}
|
|
if (this._screenStream) {
|
|
const vt = this._screenStream.getVideoTracks()[0];
|
|
if (vt && !peer.screenSender) {
|
|
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
|
|
}
|
|
}
|
|
const offer = await peer.pc.createOffer();
|
|
await peer.pc.setLocalDescription(offer);
|
|
this._onSignal(targetUid, { kind: 'offer', sdp: offer.sdp });
|
|
}
|
|
|
|
_getOrCreate(uid) {
|
|
if (this._peers.has(uid)) return this._peers.get(uid);
|
|
return this._createPeer(uid);
|
|
}
|
|
|
|
_getPeer(uid) { return this._peers.get(uid) || null; }
|
|
|
|
_createPeer(uid) {
|
|
const ICE = { iceServers: [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
]};
|
|
|
|
const pc = new RTCPeerConnection(ICE);
|
|
const peer = { pc, audioEl: null, screenSender: null, remoteSet: false, pendingCandidates: [], negotiating: false };
|
|
this._peers.set(uid, peer);
|
|
|
|
pc.onicecandidate = e => {
|
|
if (e.candidate) this._onSignal(uid, { kind: 'candidate', candidate: e.candidate.toJSON() });
|
|
};
|
|
|
|
/* Renegotiation: fires when tracks are added/removed (e.g. screen share) */
|
|
pc.onnegotiationneeded = async () => {
|
|
if (peer.negotiating || !peer.remoteSet) return;
|
|
peer.negotiating = true;
|
|
try {
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
this._onSignal(uid, { kind: 'offer', sdp: offer.sdp });
|
|
} catch {}
|
|
finally { peer.negotiating = false; }
|
|
};
|
|
|
|
pc.ontrack = e => {
|
|
const track = e.track;
|
|
const stream = e.streams[0] || new MediaStream([track]);
|
|
|
|
if (track.kind === 'audio') {
|
|
if (!peer.audioEl) {
|
|
peer.audioEl = document.createElement('audio');
|
|
peer.audioEl.autoplay = true;
|
|
peer.audioEl.style.display = 'none';
|
|
document.body.appendChild(peer.audioEl);
|
|
}
|
|
peer.audioEl.srcObject = stream;
|
|
this._startVAD(uid, stream);
|
|
} else if (track.kind === 'video') {
|
|
if (this._onScreen) this._onScreen(stream);
|
|
track.onended = () => { if (this._onScreen) this._onScreen(null); };
|
|
}
|
|
};
|
|
|
|
return peer;
|
|
}
|
|
|
|
/* ── Screen sharing (teacher) ───────────────────────���─────────────────── */
|
|
|
|
async startScreenShare(constraints = {}) {
|
|
try {
|
|
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { cursor: 'always', ...constraints.video },
|
|
audio: constraints.audio ?? false,
|
|
});
|
|
} catch { return null; }
|
|
|
|
const vt = this._screenStream.getVideoTracks()[0];
|
|
vt.onended = () => this.stopScreenShare();
|
|
|
|
// Add to all existing peer connections (onnegotiationneeded will renegotiate)
|
|
for (const [, peer] of this._peers) {
|
|
if (!peer.screenSender) {
|
|
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
|
|
}
|
|
}
|
|
|
|
return this._screenStream;
|
|
}
|
|
|
|
async stopScreenShare() {
|
|
if (!this._screenStream) return;
|
|
this._screenStream.getTracks().forEach(t => t.stop());
|
|
this._screenStream = null;
|
|
|
|
for (const [, peer] of this._peers) {
|
|
if (peer.screenSender) {
|
|
try { peer.pc.removeTrack(peer.screenSender); } catch {}
|
|
peer.screenSender = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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 ─────────────────────────────────────────────────────────── */
|
|
|
|
destroy() {
|
|
this._stopAllVAD();
|
|
for (const uid of [...this._peers.keys()]) this.removePeer(uid);
|
|
this.stopAudio();
|
|
if (this._screenStream) {
|
|
this._screenStream.getTracks().forEach(t => t.stop());
|
|
this._screenStream = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof module !== 'undefined') module.exports = ClassroomRTC;
|