aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 23:19:11 -0700
committerAdam Soltys <[email protected]>2026-10-18 23:19:11 -0700
commitd007dbec5d44963b4650baf9668389ec7da944f3 (patch)
treeb93a4adcccdf4031a9b7239ff8aa38e67c901d97 /src
parentdb3eda32859130781ee25aa462f2ae5839f3feb3 (diff)
downloadcoinosv3-d007dbec5d44963b4650baf9668389ec7da944f3.tar.gz
coinosv3-d007dbec5d44963b4650baf9668389ec7da944f3.zip
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 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src')
-rw-r--r--src/calls.js30
-rw-r--r--src/features/messages.js10
-rw-r--r--src/i18n.js1
3 files changed, 37 insertions, 4 deletions
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(); },
};
diff --git a/src/features/messages.js b/src/features/messages.js
index 9604f6e..887f327 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -9532,6 +9532,7 @@ export function messagesFeature(ctx) {
if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
const video = c.type === 'video';
const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0;
+ const camOn = !!c.local && c.local.getVideoTracks().length > 0;
const sound = h('audio', { class: 'call-audio', 'data-key': 'call-audio:' + c.id, autoplay: '' });
const status = c.state === 'incoming' ? t(video ? 'callIncomingVideo' : 'callIncomingVoice')
: c.state === 'starting' || c.state === 'offering' ? t('callCalling')
@@ -9551,7 +9552,7 @@ export function messagesFeature(ctx) {
avatar(c.peer, 'chat-avatar profile-avatar call-avatar', false),
h('div', { class: 'call-name' }, displayName(c.peer)),
h('div', { class: 'call-status' }, status)),
- video && c.local ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
+ video && c.local && c.local.getVideoTracks().length ? h('video', { class: 'call-local' + (c.facing === 'user' ? ' mirror' : ''), 'data-key': 'call-local:' + c.id, autoplay: '', playsinline: '', muted: '' }) : null,
c.state === 'incoming'
? h('div', { class: 'call-bar' },
btn('call-decline', 'hang', t('callDecline'), () => callsNow().reject()),
@@ -9559,8 +9560,11 @@ export function messagesFeature(ctx) {
: c.state === 'ended' ? null
: h('div', { class: 'call-bar' },
btn('', c.muted ? 'micOff' : 'mic', t(c.muted ? 'callUnmute' : 'callMute'), () => callsNow().setMuted(!c.muted), c.muted),
- video ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
- video ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ // a camera in the call: off/on and flip; none yet (a voice call, or
+ // theirs came on first): one tap puts ours in
+ camOn ? btn('', c.camOff ? 'camOff' : 'video', t(c.camOff ? 'callCamOn' : 'callCamOff'), () => callsNow().setCamOff(!c.camOff), c.camOff) : null,
+ camOn ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
+ !camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
}
diff --git a/src/i18n.js b/src/i18n.js
index 613fae4..c2d0c86 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -537,6 +537,7 @@ const STR = {
callUnmute: 'Unmute',
callCamOff: 'Turn camera off',
callCamOn: 'Turn camera on',
+ callAddVideo: 'Turn on camera',
callFlip: 'Switch camera',
callMinimize: 'Minimize',
callEnded: 'Call ended',