summaryrefslogtreecommitdiff
path: root/public/js/share.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/share.js')
-rw-r--r--public/js/share.js267
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 };
+})();