From d007dbec5d44963b4650baf9668389ec7da944f3 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 18 Oct 2026 23:19:11 -0700 Subject: A voice call can turn video: Turn on camera in the call's bar puts your camera in (a renegotiation, NIP-AC's kind 25055); the other side sees you at once and gets the same button for theirs. The call's sound carries on throughout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/calls.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) (limited to 'src/calls.js') diff --git a/src/calls.js b/src/calls.js index 6f18aec..e95b975 100644 --- a/src/calls.js +++ b/src/calls.js @@ -251,6 +251,32 @@ export function createCalls(env) { for (const t of call.local.getVideoTracks()) t.enabled = !on; changed(); } + // A voice call turned video: our camera into the call, by a new offer + // (NIP-AC's renegotiate). Into the video transceiver their camera already + // came by if there is one, else a new one. The other side sees us at once + // and gets the same button for theirs. + async function addVideo() { + const c = call; + if (!c || c.state !== 'connected' || !c.pc || !c.local || c.local.getVideoTracks().length) return; + try { + const fresh = await env.media('video-only', c.facing); + const track = fresh.getVideoTracks()[0]; + if (call !== c || c.state !== 'connected') { track.stop(); return; } + // a new stream object, so the preview picks the camera up + c.local = new MediaStream([...c.local.getTracks(), track]); + 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); tr.direction = 'sendrecv'; try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} } + else c.pc.addTrack(track, c.local); + c.type = 'video'; c.camOff = false; + changed(); + await renegotiate(c); + } catch {} + } + async function renegotiate(c) { + c.renegotiating = true; + await c.pc.setLocalDescription(await c.pc.createOffer()); + await sendFor(c, CALL.RENEGOTIATE, c.peer, c.pc.localDescription.sdp); + } // the other camera: the sender's track swapped, no new offer needed async function flip() { const c = call; @@ -357,6 +383,8 @@ export function createCalls(env) { await c.pc.setRemoteDescription({ type: 'offer', sdp: ev.content }); await c.pc.setLocalDescription(await c.pc.createAnswer()); await sendFor(c, CALL.ANSWER, c.peer, c.pc.localDescription.sdp); + // they turned their camera on: this is a video call now + if (c.type !== 'video' && /^m=video [1-9]/m.test(ev.content)) { c.type = 'video'; changed(); } } catch {} return; } @@ -365,7 +393,7 @@ export function createCalls(env) { return { get call() { return call; }, - start, accept, reject, hangup, setMuted, setCamOff, flip, handle, + start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, 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