/* global NAF */ // "My screen": joining a room from a computer to show what's on it, rather // than as a person. You pick a screen, window or tab; everyone in the room sees // it on a big screen (components/screens.js) and hears its sound from there. // // Sound: Chrome and Edge can include a tab's sound (and on Windows, the whole // computer's) with what you share. Firefox and Safari never include sound, so // you can also pick any sound input instead. On Linux every output has a // "Monitor of …" input that carries everything playing on it; on Windows it's // "Stereo Mix" when enabled, and on a Mac a loopback device like BlackHole. // // What's shared goes out as its own WebRTC stream named 'screen', on the same // connections everyone already has with you (through the relay when "Hide my // IP address" is on). The server never sees it. window.ScreenShare = (function () { const STREAM = 'screen'; const VIDEO = { frameRate: { max: 30 }, width: { max: 1920 }, height: { max: 1080 } }; // For music and films rather than a voice: leave it exactly as it is. const SOUND = { echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: { ideal: 2 } }; const VIDEO_BITRATE = 3000000; const AUDIO_BITRATE = 128000; const LOOPBACK = /monitor|stereo mix|what u hear|loopback|blackhole|soundflower|cable output/i; const $ = (id) => document.getElementById(id); function supported() { return Boolean(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia); } // Asks what to share. Chrome-only options are ignored elsewhere; a browser // that refuses the sound request outright gets asked again without it. async function pick() { const options = { video: VIDEO, audio: SOUND, systemAudio: 'include', // Chrome on Windows: offer the whole computer's sound selfBrowserSurface: 'exclude', // not this tab, which would show itself surfaceSwitching: 'include', // Chrome: "Share this tab instead" while sharing }; try { return await navigator.mediaDevices.getDisplayMedia(options); } catch (err) { if (err.name !== 'TypeError' && err.name !== 'NotSupportedError' && err.name !== 'OverconstrainedError') throw err; return navigator.mediaDevices.getDisplayMedia({ video: true }); } } // Runs the "My screen" page once you're in the room. `first` is what you // picked when you clicked Join, if you picked something. function run({ name, title, first, showBanner }) { let display = null; // what you picked let sound = null; // the sound track going out with it, if any let soundFrom = 'none'; // 'display', 'none' or a sound input's deviceId let published = null; // the copies on their way to everyone let connected = false; const preview = $('share-preview'); const select = $('share-sound'); $('share-title').textContent = title; function render() { const sharing = Boolean(display); $('share-status').textContent = !connected ? 'Connecting…' : sharing ? 'Everyone in the room can see this.' : 'Not sharing anything.'; $('share-btn').textContent = sharing ? 'Share something else' : 'Share screen'; $('stop-share-btn').hidden = !sharing; preview.hidden = !sharing; $('sound-field').hidden = !sharing; renderSound(); } function renderSound() { const shown = new Set([...select.options].map((o) => o.value)); const hasDisplaySound = Boolean(display && display.getAudioTracks().length); if (hasDisplaySound && !shown.has('display')) select.prepend(new Option('From what you share', 'display')); if (!hasDisplaySound && shown.has('display')) select.querySelector('option[value="display"]').remove(); if (!shown.has('none')) select.append(new Option('No sound', 'none')); select.value = soundFrom; const hint = $('sound-hint'); const chosen = select.selectedOptions[0]; if (soundFrom === 'display') { hint.textContent = ''; } else if (soundFrom === 'none') { hint.textContent = hasDisplaySound ? '' : 'Your browser didn\'t include sound. Choose a sound input to share sound.'; } else if (chosen && LOOPBACK.test(chosen.textContent)) { hint.textContent = 'This carries everything your computer plays, so don\'t also join the room from this computer (people would hear themselves).'; } else { hint.textContent = 'For everything your computer plays, choose "Monitor of …" (Linux), "Stereo Mix" (Windows) or a loopback device (Mac).'; } $('sound-btn').hidden = [...select.options].some((o) => o.value !== 'display' && o.value !== 'none'); } // Lists sound inputs by name. Browsers only give names once you've allowed one. async function listInputs() { let devices = []; try { devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audioinput' && d.deviceId && d.label); } catch (err) { return; } for (const option of [...select.options]) if (option.value !== 'display' && option.value !== 'none') option.remove(); devices.sort((a, b) => LOOPBACK.test(b.label) - LOOPBACK.test(a.label)); for (const d of devices) select.append(new Option(d.label, d.deviceId)); } async function useInput(constraints) { const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints }); const track = stream.getAudioTracks()[0]; setSound(track, track.getSettings().deviceId || 'none'); await listInputs(); render(); } function setSound(track, from) { if (sound && sound !== track && soundFrom !== 'display') sound.stop(); sound = track; soundFrom = track ? from : 'none'; if (track) track.addEventListener('ended', () => { if (sound !== track) return; sound = null; soundFrom = 'none'; publish(); render(); }); publish(); } // Sends copies of what you're sharing, so swapping the sound or the // picture can stop the old copies without stopping what you picked. function publish() { const adapter = NAF.connection.adapter; if (!connected || !adapter) return; if (published) { adapter.removeLocalMediaStream(STREAM); published = null; } const video = display && display.getVideoTracks()[0]; if (!video || video.readyState !== 'live') return; const tracks = [video.clone()]; if ('contentHint' in tracks[0]) tracks[0].contentHint = 'motion'; if (sound && sound.readyState === 'live') tracks.push(sound.clone()); published = new MediaStream(tracks); adapter.addLocalMediaStream(published, STREAM); NAF.connection.broadcastDataGuaranteed('screen', { op: 'on', name }); tuneSoon(); } // Room for a clear picture and full sound. The senders only take this once // they're set up, so it's tried again a little later. let tuneTimers = []; function tuneSoon() { tuneTimers.forEach(clearTimeout); tuneTimers = [1500, 5000].map((ms) => setTimeout(tune, ms)); } function tune() { const adapter = NAF.connection.adapter; if (!published || !adapter) return; const ours = new Set(published.getTracks()); for (const id of Object.keys(adapter.remoteClients || {})) { const pc = adapter.easyrtc.getPeerConnectionByUserId(id); if (!pc || !pc.getSenders) continue; for (const sender of pc.getSenders()) { if (!sender.track || !ours.has(sender.track)) continue; const video = sender.track.kind === 'video'; // Not every browser takes a frame-rate cap; the bitrate matters more. const apply = (fps) => { const params = sender.getParameters(); if (!params.encodings || !params.encodings.length) return Promise.resolve(); for (const e of params.encodings) { e.maxBitrate = video ? VIDEO_BITRATE : AUDIO_BITRATE; if (fps) e.maxFramerate = 30; } return sender.setParameters(params); }; apply(video).catch(() => apply(false)).catch(() => {}); } } } function share(stream) { stop(false); display = stream; preview.srcObject = new MediaStream(stream.getVideoTracks()); preview.play().catch(() => {}); stream.getVideoTracks()[0].addEventListener('ended', () => { // The browser's own "Stop sharing", or the window closed. if (display === stream) stop(true); }); const tabSound = stream.getAudioTracks()[0]; if (tabSound) setSound(tabSound, 'display'); else if (sound) publish(); // keep the sound input you'd already chosen else setSound(null, 'none'); render(); } function stop(tell) { if (display) display.getTracks().forEach((t) => t.stop()); if (soundFrom === 'display') { sound = null; soundFrom = 'none'; } display = null; preview.srcObject = null; if (published) { NAF.connection.adapter.removeLocalMediaStream(STREAM); published = null; } if (tell && connected) NAF.connection.broadcastDataGuaranteed('screen', { op: 'off' }); render(); } $('share-btn').addEventListener('click', async () => { try { share(await pick()); } catch (err) { if (err.name !== 'NotAllowedError' && err.name !== 'AbortError') showBanner(`Couldn't share: ${err.message}`, 8000); } }); $('stop-share-btn').addEventListener('click', () => stop(true)); $('sound-btn').addEventListener('click', () => { useInput(SOUND).catch((err) => showBanner(`Couldn't use a sound input: ${err.message}`, 8000)); }); select.addEventListener('change', () => { const value = select.value; if (value === soundFrom) return; if (value === 'none') { setSound(null, 'none'); render(); } else if (value === 'display') { setSound(display.getAudioTracks()[0], 'display'); render(); } else { useInput({ ...SOUND, deviceId: { exact: value } }).catch((err) => { showBanner(`Couldn't use that sound input: ${err.message}`, 8000); render(); }); } }); // Someone new gets what you're sharing too. document.body.addEventListener('clientConnected', (evt) => { const id = evt.detail.clientId; if (!published) return; try { NAF.connection.adapter.easyrtc.addStreamToCall(id, STREAM); } catch (err) { // Already on that call. } NAF.connection.sendDataGuaranteed(id, 'screen', { op: 'on', name }); tuneSoon(); }); document.body.addEventListener('connected', () => { connected = true; publish(); render(); }, { once: true }); window.addEventListener('pagehide', () => stop(true)); if (first) share(first); render(); } return { supported, pick, run }; })();