diff options
| -rw-r--r-- | src/calls.js | 60 | ||||
| -rw-r--r-- | src/features/messages.js | 31 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/style.css | 1 | ||||
| -rw-r--r-- | tools/call-test.js | 28 | ||||
| -rw-r--r-- | tools/go-live-test.js | 14 |
6 files changed, 125 insertions, 12 deletions
diff --git a/src/calls.js b/src/calls.js index 366b00d..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,7 +112,12 @@ 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) => { @@ -285,6 +291,56 @@ export function createCalls(env) { 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; @@ -401,7 +457,7 @@ export function createCalls(env) { return { get call() { return call; }, - start, accept, reject, hangup, setMuted, setCamOff, flip, addVideo, 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(); }, }; 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()))); } diff --git a/src/i18n.js b/src/i18n.js index c2d0c86..910cae4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -537,6 +537,9 @@ const STR = { callUnmute: 'Unmute', callCamOff: 'Turn camera off', callCamOn: 'Turn camera on', + goLiveToCamera: 'Back to camera', + callShare: 'Share screen', + callStopShare: 'Stop sharing', callAddVideo: 'Turn on camera', callFlip: 'Switch camera', callMinimize: 'Minimize', diff --git a/src/style.css b/src/style.css index df5bccf..c4e5202 100644 --- a/src/style.css +++ b/src/style.css @@ -1939,6 +1939,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .call-local { position: absolute; right: 16px; bottom: 120px; width: 28vw; max-width: 180px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 12px; background: #000; box-shadow: 0 2px 12px rgba(0, 0, 0, .5); z-index: 2; } .call-local.mirror { transform: scaleX(-1); } +.call-local.sharing { aspect-ratio: 16 / 10; object-fit: contain; background: #000; } .call-bar { display: flex; gap: 18px; z-index: 2; flex-wrap: wrap; justify-content: center; } .call-btn { width: 60px; height: 60px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, .16); color: #fff; cursor: pointer; padding: 0; } diff --git a/tools/call-test.js b/tools/call-test.js index 15b3d98..d5c3909 100644 --- a/tools/call-test.js +++ b/tools/call-test.js @@ -53,7 +53,8 @@ const server = Bun.serve({ port: PORT, }, }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', - args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', + '--auto-select-desktop-capture-source=Entire screen'] }); // a screen to share, chosen without asking // our DM relays lead to the test's relay const toLocal = (port) => { const Real = window.WebSocket; @@ -166,6 +167,31 @@ try { await B.page.evaluate(() => document.querySelector('.call-add-video').click()); check('Bob turns his on: Alice sees him, both still talking', await waitFor(A.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 15000) && (await media(A.page)).audio && (await media(B.page)).audio && await live(A.page), JSON.stringify(await media(A.page))); + + console.log('\n[sharing a screen]'); + const dims = (p) => p.evaluate(() => { const v = document.querySelector('video.call-remote'); return v ? v.videoWidth + 'x' + v.videoHeight : ''; }); + const camDims = await dims(B.page); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('Alice shares her screen: her preview shows it, the button says Stop sharing', await waitFor(A.page, () => !!document.querySelector('video.call-local.sharing') && !!document.querySelector('.call-share.on[aria-label="Stop sharing"]'), 10000)); + check('...Bob sees the screen in place of her camera', await waitFor(B.page, (d) => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && v.videoWidth + 'x' + v.videoHeight !== d; }, 15000, camDims), camDims + ' → ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + const camOf = (p) => p.evaluate(() => { const v = document.querySelector('video.call-local'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + check('stopping brings her camera back: it\'s her camera she sends, Bob still sees her', await waitFor(A.page, () => { const v = document.querySelector('video.call-local:not(.sharing)'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && await waitFor(B.page, () => { const v = document.querySelector('video.call-remote'); return !!v && v.videoWidth > 0 && !v.paused; }, 10000), await camOf(A.page) + ' | Bob ' + await dims(B.page)); + await A.page.evaluate(() => document.querySelector('.call-hang').click()); + await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); + await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); + await A.page.evaluate(() => document.querySelector('.call-start[data-call="voice"]').click()); + await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept'), 15000); + await B.page.evaluate(() => document.querySelector('.call-accept').click()); + await live(A.page); await live(B.page); + await waitFor(A.page, () => !!document.querySelector('.call-share'), 3000); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('in a voice call, a shared screen shows on Bob\'s side', await waitFor(B.page, () => !!document.querySelector('.call-overlay.has-video') && document.querySelector('video.call-remote').videoWidth > 0, 15000)); + await A.page.evaluate(() => document.querySelector('.call-share').click()); + check('...and goes when she stops (no camera to come back), the call carrying on', await waitFor(B.page, () => !document.querySelector('.call-overlay.has-video'), 15000) + && await live(B.page) && (await media(B.page)).audio); + check('...and her camera never came on', await A.page.evaluate(() => !document.querySelector('video.call-local'))); await A.page.evaluate(() => document.querySelector('.call-hang').click()); await waitFor(A.page, () => !document.querySelector('.call-overlay'), 8000); await waitFor(B.page, () => !document.querySelector('.call-overlay'), 8000); diff --git a/tools/go-live-test.js b/tools/go-live-test.js index 990dc1a..6c20a7a 100644 --- a/tools/go-live-test.js +++ b/tools/go-live-test.js @@ -44,7 +44,8 @@ const server = Bun.serve({ port: PORT, websocket, fetch: async (req, srv) => { return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', - args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] }); + args: ['--no-sandbox', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', + '--auto-select-desktop-capture-source=Entire screen'] }); const relayStub = (fx) => { // what the relays held before a reload (a test can carry events across one) @@ -140,6 +141,17 @@ try { const m3u8 = await (await fetch(hls)).text(); check('the server is making HLS of it, three segments in when it\'s announced', (m3u8.match(/\.ts/g) || []).length >= 3 && !/ENDLIST/.test(m3u8), (m3u8.match(/\.ts/g) || []).length + ' segments'); + console.log('\n[camera ⇄ screen while live]'); + const sending = () => page.evaluate(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return t ? t.label + ' ' + t.readyState : ''; }); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('Share screen, live: the stream carries on with the screen', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && /^screen/.test(t.label) && t.readyState === 'live'; }, 10000) + && !!(await page.$('.bc-bar')) && await page.evaluate(() => document.querySelector('.bc-switch').textContent === 'Back to camera'), await sending()); + await page.evaluate(() => document.querySelector('.bc-switch').click()); + check('...and Back to camera returns to it, still live', await waitFor(() => { const v = document.querySelector('.live-page video.bc-preview'); const t = v && v.srcObject && v.srcObject.getVideoTracks()[0]; return !!t && !/^screen/.test(t.label) && t.readyState === 'live'; }, 10000) && !!(await page.$('.bc-bar')), await sending()); + await sleep(4000); + const segsNow = ((await (await fetch(hls)).text()).match(/\.ts/g) || []).length; + check('...the server still making HLS through the switches', segsNow >= 3, segsNow + ' segments in the window'); + console.log('\n[someone watching]'); const viewer = await browser.newPage(); await viewer.setViewport({ width: 390, height: 844 }); |
