aboutsummaryrefslogtreecommitdiffstats
path: root/src/calls.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/calls.js')
-rw-r--r--src/calls.js102
1 files changed, 97 insertions, 5 deletions
diff --git a/src/calls.js b/src/calls.js
index 6f18aec..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,11 +112,21 @@ 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) => {
- if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return;
+ // the routes found before the offer or answer went ride inside it;
+ // only the late ones go one by one (one signed message each — dozens
+ // of them got the household's IP rate-limited on the relays, and the
+ // answer refused with them)
+ if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return;
+ if (c.described.includes(e.candidate.candidate)) return;
c.sent.push(e.candidate);
sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {});
};
@@ -160,7 +171,7 @@ export function createCalls(env) {
const pc = c.pc;
const enough = () => pc.iceGatheringState === 'complete'
|| (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
- if (!c.trickle && !enough()) {
+ if (!enough()) {
await new Promise((resolve) => {
const check = () => { if (enough()) resolve(); };
pc.addEventListener('icegatheringstatechange', check);
@@ -168,7 +179,10 @@ export function createCalls(env) {
setTimeout(resolve, 1500);
});
}
- return pc.localDescription.sdp;
+ const sdp = pc.localDescription.sdp;
+ // what it carries isn't sent again; what comes later is (trickle)
+ c.described = sdp;
+ return sdp;
}
async function addCandidates(c) {
const list = [...(early.get(c.peer) || []), ...c.ice];
@@ -251,6 +265,82 @@ 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);
+ }
+ // 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;
@@ -357,6 +447,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 +457,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, 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(); },
};