From e571187361ec9af1db8fba9050c23ec10db677f4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 19 Oct 2026 00:01:52 -0700 Subject: Screen sharing. In a call: Share screen in the call's bar (where the browser can — desktops) puts your screen in your camera's place, or into the call if your camera's off; Stop sharing, or the browser's own, brings the camera back or stops the picture, and the other side's view follows (a picture that stops coming is hidden). Live: Share screen / Back to camera on the broadcast bar switches without stopping the stream; the browser's Stop sharing goes back to the camera instead of ending it, and a cancelled screen picker leaves the camera going. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/calls.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) (limited to 'src/calls.js') diff --git a/src/calls.js b/src/calls.js index 366b00d..8ff32e9 100644 --- a/src/calls.js +++ b/src/calls.js @@ -89,6 +89,7 @@ export function createCalls(env) { function stopMedia(c) { for (const s of [c.local]) if (s) for (const t of s.getTracks()) { try { t.stop(); } catch {} } + for (const t of [c.cam, c.screen]) if (t) { try { t.stop(); } catch {} } // the camera set aside while sharing try { c.pc && c.pc.close(); } catch {} } function end(reason, c = call) { @@ -111,7 +112,12 @@ export function createCalls(env) { const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle', iceCandidatePoolSize: 2 }); c.pc = pc; c.remote = new MediaStream(); - pc.ontrack = (e) => { for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); changed(); }; + pc.ontrack = (e) => { + for (const t of (e.streams[0] || { getTracks: () => [e.track] }).getTracks()) if (!c.remote.getTracks().includes(t)) c.remote.addTrack(t); + // a picture that stops coming (they stopped sharing, camera and all) mutes + e.track.onmute = e.track.onunmute = changed; + changed(); + }; c.trickle = env.trickle ? env.trickle() !== false : true; c.sent = []; pc.onicecandidate = (e) => { @@ -285,6 +291,56 @@ export function createCalls(env) { await c.pc.setLocalDescription(await c.pc.createOffer()); await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp); } + // Your screen in the call: in the camera's place if it's on (a swap, no new + // offer), else into the video slot by a renegotiation, as addVideo does. + // Stopping — here or from the browser's own Stop sharing — brings the + // camera back if it was on, else stops sending a picture. + async function shareScreen() { + const c = call; + if (!c || c.state !== 'connected' || !c.pc || c.sharing) return; + let track; + try { track = (await env.media('screen')).getVideoTracks()[0]; } catch { return; } + if (!track) return; + if (call !== c || c.state !== 'connected') { track.stop(); return; } + try { track.contentHint = 'detail'; } catch {} + const cam = c.local.getVideoTracks()[0] || null; + Object.assign(c, { sharing: true, screen: track, cam }); + c.local = new MediaStream([...c.local.getAudioTracks(), track]); + let renego = false; + const sender = c.pc.getSenders().find((x) => x.track && x.track.kind === 'video'); + if (sender) await sender.replaceTrack(track); + else { + const tr = c.pc.getTransceivers().find((x) => !x.stopped && !x.sender.track && x.receiver.track && x.receiver.track.kind === 'video'); + if (tr) { await tr.sender.replaceTrack(track); if (tr.direction !== 'sendrecv') { tr.direction = 'sendrecv'; renego = true; } } + else { c.pc.addTrack(track, c.local); renego = true; } + } + c.type = 'video'; + track.onended = () => { if (call === c && c.screen === track) stopShare(); }; + changed(); + if (renego) await renegotiate(c).catch(() => {}); + } + async function stopShare() { + const c = call; + if (!c || !c.sharing) return; + const track = c.screen, cam = c.cam; + Object.assign(c, { sharing: false, screen: null, cam: null }); + try { track.stop(); } catch {} + if (!c.pc || c.state === 'ended') return; + const sender = c.pc.getSenders().find((x) => x.track === track); + if (cam && cam.readyState === 'live') { + if (sender) await sender.replaceTrack(cam).catch(() => {}); + c.local = new MediaStream([...c.local.getAudioTracks(), cam]); + changed(); + return; + } + c.local = new MediaStream(c.local.getAudioTracks()); + changed(); + if (sender) { + await sender.replaceTrack(null).catch(() => {}); + const tr = c.pc.getTransceivers().find((x) => x.sender === sender); + if (tr && !tr.stopped) { tr.direction = 'recvonly'; await renegotiate(c).catch(() => {}); } + } + } // the other camera: the sender's track swapped, no new offer needed async function flip() { const c = call; @@ -401,7 +457,7 @@ export function createCalls(env) { return { get call() { return call; }, - start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, handle, + start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, shareScreen, stopShare, handle, // a sign-out or account switch: whatever was going on ends here stop() { if (call && call.state !== 'ended') hangup(); call = null; early.clear(); }, }; -- cgit v1.2.3