aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-19 00:01:52 -0700
committerAdam Soltys <[email protected]>2026-10-19 00:01:52 -0700
commite571187361ec9af1db8fba9050c23ec10db677f4 (patch)
treef650d51d5e0de7ffb3b79db165509643c08c3782 /src/features/messages.js
parent8db6bd1e3dff5d55eb82a854149a387b5d718148 (diff)
downloadcoinosv3-e571187361ec9af1db8fba9050c23ec10db677f4.tar.gz
coinosv3-e571187361ec9af1db8fba9050c23ec10db677f4.zip
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.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js31
1 files changed, 23 insertions, 8 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7efbfd6..0b6d8f0 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -9046,7 +9046,10 @@ export function messagesFeature(ctx) {
h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })),
bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting'))
: bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null,
- h('button', { class: 'btn-sm bc-end', style: 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
+ // camera ⇄ screen, without stopping the stream
+ navigator.mediaDevices?.getDisplayMedia ? h('button', { class: 'btn-sm bc-switch', style: 'margin-left:auto', onClick: () => { bc.source = bc.source === 'screen' ? 'camera' : 'screen'; bcPreview(); } },
+ t(bc.source === 'screen' ? 'goLiveToCamera' : 'callShare')) : null,
+ h('button', { class: 'btn-sm bc-end', style: navigator.mediaDevices?.getDisplayMedia ? undefined : 'margin-left:auto', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, t('goLiveEnd'))) : null,
mine ? h('div', { class: 'live-player', 'data-key': 'bc-player:' + s.a },
h('div', { class: 'live-frame', 'data-key': 'bc-frame:' + (bc.media ? bc.media.id : '') },
h('video', { class: 'live-video bc-preview' + (bc.source === 'camera' && bc.facing === 'user' ? ' mirror' : ''), muted: '', playsinline: '', autoplay: '' })))
@@ -9126,7 +9129,10 @@ export function messagesFeature(ctx) {
async function bcPreview() {
const b = bc;
if (!b) return;
- bcStopMedia();
+ // live, the old picture keeps going out until the new one is there (a
+ // cancelled screen picker mustn't leave the stream with nothing)
+ const live = b.phase === 'live', was = b.source === 'screen' ? 'camera' : 'screen';
+ if (!live) bcStopMedia();
b.err = null;
try {
let media;
@@ -9134,8 +9140,8 @@ export function messagesFeature(ctx) {
media = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true });
const mic = await navigator.mediaDevices.getUserMedia({ audio: true }).catch(() => null);
if (mic && !media.getAudioTracks().length) for (const tr of mic.getAudioTracks()) media.addTrack(tr);
- // the browser's own "Stop sharing" ends the stream
- media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live') endBroadcast(); });
+ // the browser's own "Stop sharing", live: back to the camera
+ media.getVideoTracks()[0]?.addEventListener('ended', () => { if (bc === b && b.phase === 'live' && b.media === media) { b.source = 'camera'; bcPreview(); } });
} else {
media = await navigator.mediaDevices.getUserMedia({
video: { facingMode: b.facing, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
@@ -9143,11 +9149,14 @@ export function messagesFeature(ctx) {
});
}
if (bc !== b) { for (const tr of media.getTracks()) tr.stop(); return; }
+ if (live) bcStopMedia();
b.media = media;
// a switch mid-stream: the recorder carries on with the new picture
if (b.phase === 'live') bcRecord(b, true);
} catch (e) {
- if (bc === b) b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
+ if (bc !== b) return;
+ if (live) b.source = was; // no screen after all: the camera goes on
+ else b.err = t(b.source === 'screen' ? 'goLiveNoScreen' : 'goLiveNoCamera');
}
render();
}
@@ -9416,6 +9425,7 @@ export function messagesFeature(ctx) {
const video = { facingMode: facing, width: { ideal: 1280 }, height: { ideal: 720 } };
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
if (type === 'video-only') return navigator.mediaDevices.getUserMedia({ video });
+ if (type === 'screen') return navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 } }, audio: false });
return navigator.mediaDevices.getUserMedia(type === 'video' ? { audio, video } : { audio });
}
// who may ring: someone you follow, or someone you've written to
@@ -9659,6 +9669,7 @@ export function messagesFeature(ctx) {
micOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M9 9v1a3 3 0 0 0 5.1 2.1M15 9.3V5a3 3 0 0 0-5.9-.6M17 16.9A7 7 0 0 1 5 10M19 10a7 7 0 0 1-.3 2M12 17v5"/></svg>',
camOff: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m2 2 20 20M16 16v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m4 0h4a2 2 0 0 1 2 2v3.3l1 1 5-3.3v8"/></svg>',
flip: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-3l-2-3H9L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="m14 11 2 2-2 2M10 15l-2-2 2-2M8 13h8"/></svg>',
+ screen: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4M9 10l3-3 3 3M12 7v6"/></svg>',
hang: '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-1.6 0-3.2.3-4.6.7v3.1c0 .4-.2.7-.6.9-1 .5-1.9 1.1-2.7 1.8-.2.2-.4.3-.7.3s-.5-.1-.7-.3L.3 13.1c-.2-.2-.3-.4-.3-.7s.1-.5.3-.7C3.3 8.9 7.4 7 12 7s8.7 1.9 11.7 4.7c.2.2.3.4.3.7s-.1.5-.3.7l-2.4 2.4c-.2.2-.4.3-.7.3s-.5-.1-.7-.3c-.8-.7-1.7-1.3-2.7-1.8-.3-.2-.6-.5-.6-.9V9.7C15.2 9.3 13.6 9 12 9z"/></svg>',
min: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 14 6 0 0 6M20 10l-6 0 0-6M14 10l7-7M3 21l7-7"/></svg>',
};
@@ -9671,7 +9682,8 @@ export function messagesFeature(ctx) {
queueMicrotask(mountCallMedia);
if (c.state === 'connected') setTimeout(scheduleRepaint, 1000); // the clock
const video = c.type === 'video';
- const remoteVideo = video && c.remote && c.remote.getVideoTracks().length > 0;
+ // a picture that's coming (a muted track: they stopped their camera or share)
+ const remoteVideo = video && c.remote && c.remote.getVideoTracks().some((x) => x.readyState === 'live' && !x.muted);
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')
@@ -9692,7 +9704,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 && c.local.getVideoTracks().length ? 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' && !c.sharing ? ' mirror' : '') + (c.sharing ? ' sharing' : ''), '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()),
@@ -9703,8 +9715,11 @@ export function messagesFeature(ctx) {
// 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.sharing ? btn('', 'flip', t('callFlip'), () => callsNow().flip()) : null,
!camOn && c.state === 'connected' ? btn('call-add-video', 'video', t('callAddVideo'), () => callsNow().addVideo()) : null,
+ // a browser that can share its screen (desktops; not phones' Chrome)
+ c.state === 'connected' && navigator.mediaDevices?.getDisplayMedia
+ ? btn('call-share', 'screen', t(c.sharing ? 'callStopShare' : 'callShare'), () => (c.sharing ? callsNow().stopShare() : callsNow().shareScreen()), !!c.sharing) : null,
btn('', 'min', t('callMinimize'), () => { ui.callMin = true; render(); }),
btn('call-hang', 'hang', t('callHangUp'), () => callsNow().hangup())));
}