diff options
Diffstat (limited to 'public/js/share.js')
| -rw-r--r-- | public/js/share.js | 267 |
1 files changed, 267 insertions, 0 deletions
diff --git a/public/js/share.js b/public/js/share.js new file mode 100644 index 0000000..faff4c9 --- /dev/null +++ b/public/js/share.js @@ -0,0 +1,267 @@ +/* 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 }; +})(); |