From e571187361ec9af1db8fba9050c23ec10db677f4 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 19 Oct 2026 00:01:52 -0700 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg --- src/features/messages.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) (limited to 'src/features') 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: '', camOff: '', flip: '', + screen: '', hang: '', min: '', }; @@ -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()))); } -- cgit v1.2.3