diff options
Diffstat (limited to 'src/calls.js')
| -rw-r--r-- | src/calls.js | 66 |
1 files changed, 54 insertions, 12 deletions
diff --git a/src/calls.js b/src/calls.js index 66d6f16..6f18aec 100644 --- a/src/calls.js +++ b/src/calls.js @@ -71,6 +71,8 @@ export const candidateJson = (c) => JSON.stringify({ candidate: c.candidate, sdp // trickle() false for a signer that's slow or asks each time (a // bunker, Amber): the candidates go inside the offer or // answer instead of one signed message each +// warm(pk) a call with them may be coming: look up what sending to +// them takes (their relays, TURN) ahead of it // RTCPeerConnection (tests may pass their own) export function createCalls(env) { const PC = env.RTCPeerConnection || globalThis.RTCPeerConnection; @@ -103,8 +105,10 @@ export function createCalls(env) { endTimer = setTimeout(() => { if (call === c) { call = null; changed(); } }, ENDED_MS); } + // (iceCandidatePoolSize: the routes — TURN's included, which take a round + // trip to allocate — are gathered from the start, not from the answer on) async function connection(c) { - const pc = new PC({ iceServers: await env.iceServers(), bundlePolicy: 'max-bundle' }); + 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(); }; @@ -121,17 +125,47 @@ export function createCalls(env) { if (s === 'connected' && c.state !== 'connected') { c.state = 'connected'; c.connectedAt = Date.now(); clearTimeout(ringTimer); changed(); } else if (s === 'failed') end('failed', c); }; - for (const t of c.local.getTracks()) pc.addTrack(t, c.local); + if (c.local) for (const t of c.local.getTracks()) pc.addTrack(t, c.local); return pc; } - // without trickle: the description once its candidates are gathered (a few seconds at most) + // While a call rings: its connection made and the offer set, the routes + // gathering — everything but the microphone and camera, which wait for + // Answer. Answering then only adds them and replies (it used to do all of + // this after the tap, seconds of "Connecting…"). + async function prepare(c) { + const pc = await connection(c); + if (call !== c || c.state === 'ended') { try { pc.close(); } catch {} return null; } + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + return pc; + } + // our tracks into the transceivers the offer made + function attachLocal(c) { + const trs = c.pc.getTransceivers(); + for (const track of c.local.getTracks()) { + const tr = trs.find((x) => !x.sender.track && x.receiver.track && x.receiver.track.kind === track.kind && !x.stopped); + if (tr) { + tr.sender.replaceTrack(track); + tr.direction = 'sendrecv'; + try { tr.sender.setStreams && tr.sender.setStreams(c.local); } catch {} + } else c.pc.addTrack(track, c.local); + } + } + // Without trickle: the description once it holds routes enough — one + // through the router (srflx) and one through TURN (relay) — or after a + // second and a half. Waiting for gathering to finish took up to 4 s (TURN + // over TCP and TLS is slow to give up), all of it after the tap. async function described(c) { const pc = c.pc; - if (!c.trickle && pc.iceGatheringState !== 'complete') { + const enough = () => pc.iceGatheringState === 'complete' + || (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp)); + if (!c.trickle && !enough()) { await new Promise((resolve) => { - const done = () => { if (pc.iceGatheringState === 'complete') resolve(); }; - pc.addEventListener('icegatheringstatechange', done); - setTimeout(resolve, 4000); + const check = () => { if (enough()) resolve(); }; + pc.addEventListener('icegatheringstatechange', check); + pc.addEventListener('icecandidate', check); + setTimeout(resolve, 1500); }); } return pc.localDescription.sdp; @@ -169,12 +203,18 @@ export function createCalls(env) { c.state = 'connecting'; changed(); try { - c.local = await env.media(c.type, c.facing); + const [local, ready] = await Promise.all([env.media(c.type, c.facing), (c.prep || Promise.resolve(null)).catch(() => null)]); + c.local = local; if (call !== c) { stopMedia(c); return; } - const pc = await connection(c); - await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); - c.remoteSet = true; - await addCandidates(c); + let pc = ready; + if (pc && pc.signalingState === 'have-remote-offer') attachLocal(c); + else { // nothing made while it rang (or it failed): all of it now + try { pc && pc.close(); } catch {} + pc = await connection(c); + await pc.setRemoteDescription({ type: 'offer', sdp: c.offer }); + c.remoteSet = true; + await addCandidates(c); + } await pc.setLocalDescription(await pc.createAnswer()); const signed = await env.sign(callEvent(CALL.ANSWER, c.peer, c.id, await described(c))); await env.send(c.peer, signed); @@ -261,6 +301,8 @@ export function createCalls(env) { const n = call = { id, peer: from, type, dir: 'in', state: 'incoming', offer: ev.content, ice: [], at: Date.now(), offerAt: ev.created_at * 1000, muted: false, camOff: false, facing: 'user' }; ringTimer = setTimeout(() => { if (call === n && n.state === 'incoming') end('missed', n); }, Math.max(5000, RING_MS - (Date.now() - ev.created_at * 1000))); changed(); + try { if (env.warm) env.warm(from); } catch {} + n.prep = prepare(n).catch(() => null); return; } case CALL.ANSWER: { |
