// Manages named media sources on an RTCPeerConnection. // // Each "source" has a label (e.g. 'camera', 'screen', 'mic') and maps to // one MediaStream plus the RTCRtpSenders the peer connection created for it. // // Usage: // const sm = new StreamManager(peerConnection); // sm.addStream('camera', await navigator.mediaDevices.getUserMedia({ video: true })); // await sm.replaceTrack('camera', newVideoTrack); // hot-swap, no renegotiation // await sm.setEncodings('camera', 'video', { maxBitrate: 2_000_000 }); // sm.removeStream('camera'); // stops tracks + renegotiates export default class StreamManager { constructor(pc) { this._pc = pc; // PeerConnection instance this._sources = new Map(); // label → { stream, senders } } // Add all tracks from a MediaStream. Triggers renegotiation. addStream(label, stream) { if (this._sources.has(label)) { throw new Error(`StreamManager: "${label}" already exists — call removeStream first`); } const senders = []; for (const track of stream.getTracks()) { const sender = this._pc.addTrack(track, stream); senders.push(sender); } this._sources.set(label, { stream, senders }); return senders; } // Swap one track inside an existing source by kind ('video' | 'audio'). // Uses RTCRtpSender.replaceTrack — no renegotiation needed. async replaceTrack(label, newTrack) { const src = this._sources.get(label); if (!src) throw new Error(`StreamManager: source "${label}" not found`); for (const sender of src.senders) { if (sender.track?.kind === newTrack.kind) { await sender.replaceTrack(newTrack); return sender; } } throw new Error(`StreamManager: no ${newTrack.kind} sender in "${label}"`); } // Remove a source: stops all its tracks and calls removeTrack (renegotiates). removeStream(label) { const src = this._sources.get(label); if (!src) return; for (const sender of src.senders) { try { this._pc.removeTrack(sender); } catch (_) {} } for (const track of src.stream.getTracks()) { track.stop(); } this._sources.delete(label); } // Adjust encoding parameters (bitrate, framerate, resolution scale) for a // specific source+kind. RTCRtpSender.setParameters is used — no renegotiation. // // params: { maxBitrate, maxFramerate, scaleResolutionDownBy, ... } async setEncodings(label, kind, params) { const src = this._sources.get(label); if (!src) return; for (const sender of src.senders) { if (sender.track?.kind !== kind) continue; const p = sender.getParameters(); if (!p.encodings?.length) p.encodings = [{}]; p.encodings = p.encodings.map(enc => ({ ...enc, ...params })); await sender.setParameters(p); return; } } // Mute/unmute a track by label+kind (local-only, no signaling). setEnabled(label, kind, enabled) { const src = this._sources.get(label); if (!src) return; for (const track of src.stream.getTracks()) { if (track.kind === kind) track.enabled = enabled; } } // Return a snapshot of all managed sources. list() { const out = []; for (const [label, { stream, senders }] of this._sources) { out.push({ label, tracks: stream.getTracks().map(t => ({ id: t.id, kind: t.kind, enabled: t.enabled, label: t.label })), senders: senders.length }); } return out; } has(label) { return this._sources.has(label); } // Get the MediaStream for a label. stream(label) { return this._sources.get(label)?.stream ?? null; } // Stop all sources and clear the map. destroy() { for (const [label] of this._sources) this.removeStream(label); } }