1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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 };
})();
|