import Logger from '@jitsi/logger'; import JitsiMeetJS from '../../react/features/base/lib-jitsi-meet'; const logger = Logger.getLogger('modules/screenshare-cast/ExternalShareReceiver'); /** * ExternalShareReceiver — direct-cast wireless screenshare, RECEIVER side. * * A remote sharer (e.g. a laptop) opens a PLAIN RTCPeerConnection straight to THIS * Jitsi Meet instance and adds its screen — and, optionally, system audio — tracks. * We terminate that peer connection here, take the track(s) off `pc.ontrack`, wrap * them as JitsiLocalTracks via the public * {@code JitsiMeetJS.createLocalTracksFromMediaStreams}, and hand them to the * conference as the local screenshare. No lib-jitsi-meet ProxyConnectionService, no * Jingle, no XMPP/Strophe JIDs. * * This replaces the brittle ProxyConnectionService path. The shape — terminate the * peer connection at the TV's Jitsi Meet and let Jitsi move the tracks from this * "proxy" PC into the conference PC — is exactly what Spot's screenshare always did, * minus the Jingle/XMPP plumbing that made it Chromium-only and audio-less. Built on a * vanilla RTCPeerConnection so screenshare audio rides along for free and Firefox/Safari * are no longer locked out the way ProxyConnectionService locked them. * * Signalling is plain JSON, carried over any transport the embedder wires up (e.g. the * iframe external API) — never XMPP: * { kind: 'offer', sdp } sharer → here (answered with { kind:'answer', sdp }) * { kind: 'answer', sdp } here → sharer * { kind: 'candidate', candidate } both ways * { kind: 'stop' } sharer → here (tears down) */ export default class ExternalShareReceiver { /** * @param {Object} options * @param {RTCConfiguration} [options.pcConfig] - ICE config for the peer * connection. The meeting's own TURN creds work here (same source the old proxy * borrowed) — just without the Jingle. * @param {Function} options.onSignal - Emit a signalling message back to the * sharer: {@code (signal) => void}. * @param {Function} options.onTracks - Publish the received screenshare: * {@code ({ desktopVideoTrack, desktopAudioTrack }) => void}. * @param {Function} [options.onClosed] - Invoked once when the connection closes: * {@code ({ published, spontaneous }) => void}. {@code published} is whether a share * had been handed to the conference; {@code spontaneous} is whether the peer * connection dropped on its own (vs. an explicit {@link stop}). */ constructor({ pcConfig, onSignal, onTracks, onClosed }) { this._onSignal = onSignal; this._onTracks = onTracks; this._onClosed = onClosed; this._desktopVideoTrack = null; this._desktopAudioTrack = null; this._published = false; this._closed = false; this._spontaneous = false; if (!pcConfig) { // Don't fail outright — a same-LAN sharer can still connect with host // candidates — but make the missing TURN/STUN loud: a remote sharer behind // NAT will silently never reach 'connected' without it. logger.warn('external share: no ICE config supplied; the peer connection has ' + 'no STUN/TURN servers, so only same-LAN sharers will connect'); } const pc = new RTCPeerConnection(pcConfig || {}); this._pc = pc; pc.addEventListener('icecandidate', ({ candidate }) => { if (candidate) { this._onSignal({ kind: 'candidate', candidate: candidate.toJSON() }); } }); pc.addEventListener('connectionstatechange', () => { const state = pc.connectionState; logger.info(`external share pc state: ${state}`); if (state === 'connected') { // By the time the PC is connected, ontrack has fired for every track in // the offer, so we know whether audio is present. Publish exactly once. this._publish(); } else if (state === 'failed' || state === 'closed') { // Terminal only. 'disconnected' is deliberately NOT treated as terminal — // it is frequently a transient blip that recovers back to 'connected', so // tearing the cast down on it would drop the share on a momentary glitch. // The peer connection dropped on its own — flag it so the conference can // tear down a now-frozen published share (see onClosed). this._spontaneous = true; this.stop(); } }); pc.addEventListener('track', ({ track }) => this._collectTrack(track)); } /** * Handle one inbound signalling message from the sharer. * * Messages are processed strictly in arrival order: callers don't await us, so we * chain on an internal queue. Without it a trickled {@code candidate} could run its * {@code addIceCandidate} before the preceding {@code offer}'s asynchronous * {@code setRemoteDescription} has resolved — which throws ("remote description was * null") and silently drops the candidate. * * @param {Object} signal - The signalling message (see class doc for shapes). * @returns {Promise} */ handleSignal(signal) { this._signalQueue = (this._signalQueue || Promise.resolve()) .then(() => this._processSignal(signal)); return this._signalQueue; } /** * Process a single inbound signalling message. Serialized by {@link handleSignal}. * * @param {Object} signal - The signalling message (see class doc for shapes). * @private * @returns {Promise} */ async _processSignal(signal) { if (!signal || this._closed) { return; } try { if (signal.kind === 'offer') { await this._pc.setRemoteDescription(signal.sdp); const answer = await this._pc.createAnswer(); await this._pc.setLocalDescription(answer); // Emit a plain { type, sdp } so it survives structured-clone over // whatever transport carries it (an RTCSessionDescription may not). this._onSignal({ kind: 'answer', sdp: { type: this._pc.localDescription.type, sdp: this._pc.localDescription.sdp } }); } else if (signal.kind === 'candidate' && signal.candidate) { await this._pc.addIceCandidate(signal.candidate); } else if (signal.kind === 'stop') { this.stop(); } } catch (error) { // Signalling failed (bad SDP, unexpected state, etc.) — the connection can't // be salvaged, so tear it down rather than hang half-negotiated. Flag it as // spontaneous so a share that did get published is taken back down. logger.error('external share signal handling failed', error); this._spontaneous = true; this.stop(); } } /** * Wrap a received MediaStreamTrack as a JitsiLocalTrack and stash it until publish. * * @param {MediaStreamTrack} track - A track arriving over the peer connection. * @private * @returns {void} */ _collectTrack(track) { const isVideo = track.kind === 'video'; try { // Each received track gets its OWN single-track MediaStream: // createLocalTracksFromMediaStreams throws if a stream holds 0 or >1 tracks // of the requested kind. Guard the call (it runs inside the 'track' event) // so an unexpected/extra track can't leave the handler with an uncaught // throw and a half-collected state. const stream = new MediaStream([ track ]); const [ jitsiTrack ] = JitsiMeetJS.createLocalTracksFromMediaStreams([ { stream, track, mediaType: track.kind, sourceType: 'proxy', deviceId: 'proxy:screenshare-cast', videoType: isVideo ? 'desktop' : undefined } ]); if (isVideo) { this._desktopVideoTrack = jitsiTrack; } else { this._desktopAudioTrack = jitsiTrack; } logger.info(`external share collected ${track.kind} track`); } catch (error) { logger.error(`external share failed to wrap ${track.kind} track`, error); } } /** * Hand the collected screenshare track(s) to the conference, exactly once. * * @private * @returns {void} */ _publish() { if (this._published || !this._desktopVideoTrack) { return; } this._published = true; logger.info(`external share publishing (audio: ${Boolean(this._desktopAudioTrack)})`); this._onTracks({ desktopVideoTrack: this._desktopVideoTrack, desktopAudioTrack: this._desktopAudioTrack }); } /** * Tear down the peer connection. Idempotent. * * @returns {void} */ stop() { if (this._closed) { return; } this._closed = true; try { this._pc.close(); } catch (error) { // already torn down } // Dispose the wrapped track(s) regardless of whether they were published — a // double dispose is harmless, and on a pre-'connected' failure nobody else owns // them, so this is the only thing that stops them leaking. this._desktopVideoTrack?.dispose(); this._desktopAudioTrack?.dispose(); this._desktopVideoTrack = null; this._desktopAudioTrack = null; this._onClosed?.({ published: this._published, spontaneous: this._spontaneous }); } }