| index 97a3fcc..880f7c0 100644 |
| --- a/internal/meet/static/devices.js |
| +++ b/internal/meet/static/devices.js |
| @@ -13,22 +13,31 @@ |
| (() => { |
| 'use strict'; |
| const root = document.getElementById('devices'); |
| - if (!root || !navigator.mediaDevices) return; |
| - root.hidden = false; |
| + if (!root) return; |
| |
| const STORAGE_KEY = 'meet.devices'; |
| const $ = (id) => document.getElementById(id); |
| const preview = $('preview'); |
| + const pickers = $('pickers'); |
| + const cameraRow = $('camera-row'); |
| + const micRow = $('mic-row'); |
| + const speakerRow = $('speaker-row'); |
| const pickCamera = $('pick-camera'); |
| const pickMic = $('pick-mic'); |
| const pickSpeaker = $('pick-speaker'); |
| - const speakerRow = $('speaker-row'); |
| const muteButton = $('toggle-mute'); |
| const cameraButton = $('toggle-camera'); |
| const joinButton = $('join'); |
| const status = $('status'); |
| |
| let stream = null; |
| + // Bumped on every startPreview() call and captured at its start; a |
| + // call whose generation no longer matches when its getUserMedia |
| + // settles was superseded by a later device change (a quick switch |
| + // between two selects) and must not touch the shared `stream` or |
| + // the UI — it stops whatever tracks it was handed and gets out of |
| + // the way instead of leaking them or clobbering the newer attempt. |
| + let generation = 0; |
| const prefs = loadPrefs(); |
| |
| // --- preferences: read and written to localStorage only --- |
| @@ -54,46 +63,124 @@ |
| status.textContent = text; |
| } |
| |
| + // --- mute and camera-off defaults: applied to the live tracks when --- |
| + // --- there is a stream, always reflected on the toggle buttons --- |
| + // --- even when there isn't one, so the choice still holds at join. --- |
| + // |
| + // The buttons keep one fixed accessible name each ("Microphone", |
| + // "Camera") and move only aria-pressed: a button whose name changes |
| + // on every click reads, to a screen reader tracking it, as a |
| + // different control each time. meet.css already gives |
| + // [aria-pressed="true"] a non-colour cue (room.js's own toggles use |
| + // the same rule), so nothing further is needed here beyond setting |
| + // the attribute. |
| + function applyToggles() { |
| + if (stream) { |
| + for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted; |
| + for (const track of stream.getVideoTracks()) track.enabled = !prefs.cameraOff; |
| + } |
| + muteButton.setAttribute('aria-pressed', String(!!prefs.muted)); |
| + cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff)); |
| + } |
| + |
| + function stopPreview() { |
| + if (!stream) return; |
| + for (const track of stream.getTracks()) track.stop(); |
| + stream = null; |
| + } |
| + |
| + // --- wiring that needs neither media support nor a stream --- |
| + |
| + muteButton.addEventListener('click', () => { |
| + prefs.muted = !prefs.muted; |
| + savePrefs(); |
| + applyToggles(); |
| + }); |
| + cameraButton.addEventListener('click', () => { |
| + prefs.cameraOff = !prefs.cameraOff; |
| + savePrefs(); |
| + applyToggles(); |
| + }); |
| + joinButton.addEventListener('click', () => { |
| + stopPreview(); |
| + const href = root.dataset.joinHref || ''; |
| + // Belt and braces: only ever navigate to a path this page itself |
| + // rendered, never to whatever a mangled attribute happened to hold. |
| + if (href.startsWith('/')) location.assign(href); |
| + }); |
| + window.addEventListener('pagehide', stopPreview); |
| + |
| + applyToggles(); |
| + root.hidden = false; |
| + |
| + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
| + // An insecure context or a browser old enough to lack the API: |
| + // the pickers and preview have nothing to show, so they stay |
| + // hidden rather than sitting there empty and inert. Join still |
| + // works — the room is built to allow joining with no local media. |
| + preview.hidden = true; |
| + pickers.hidden = true; |
| + say('This browser cannot reach a camera or microphone here. You can still join with neither.'); |
| + joinButton.disabled = false; |
| + return; |
| + } |
| + |
| // --- the preview stream --- |
| |
| async function startPreview() { |
| + const myGen = ++generation; |
| stopPreview(); |
| + say('Asking for your camera and microphone…'); |
| const constraints = { |
| audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true, |
| video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true, |
| }; |
| + let result; |
| try { |
| - stream = await navigator.mediaDevices.getUserMedia(constraints); |
| + result = await navigator.mediaDevices.getUserMedia(constraints); |
| } catch (err) { |
| - if (prefs.camera || prefs.mic) { |
| - // The remembered device may be gone — unplugged, or its own |
| - // permission revoked — so forget it and try once more with no |
| - // constraint before giving up on media entirely. |
| - delete prefs.camera; |
| - delete prefs.mic; |
| + if (myGen !== generation) return; // superseded; its outcome no longer matters |
| + if ((err.name === 'OverconstrainedError' || err.name === 'NotFoundError') && (prefs.camera || prefs.mic)) { |
| + // A remembered device is gone — unplugged, or no longer |
| + // matching. err.constraint (typically "deviceId") names the |
| + // constraint that failed but not which of camera/mic it |
| + // belonged to, so only one remembered id is dropped per |
| + // attempt — camera first, then mic — rather than discarding |
| + // both on a single ambiguous failure; this converges within |
| + // two retries at most. |
| + if (prefs.camera) delete prefs.camera; |
| + else delete prefs.mic; |
| savePrefs(); |
| return startPreview(); |
| } |
| preview.srcObject = null; |
| - say(err.name === 'NotAllowedError' |
| - ? 'Meet needs permission to use your camera and microphone. You can still join with neither.' |
| - : 'No camera or microphone was found. You can still join with neither.'); |
| + say(errorSentence(err)); |
| applyToggles(); |
| - await fillPickers(); |
| joinButton.disabled = false; |
| + await fillPickers().catch(() => {}); |
| + return; |
| + } |
| + if (myGen !== generation) { |
| + // A newer request already won; this one's tracks are unwanted. |
| + for (const track of result.getTracks()) track.stop(); |
| return; |
| } |
| + stream = result; |
| preview.srcObject = stream; |
| applyToggles(); |
| - await fillPickers(); |
| joinButton.disabled = false; |
| + await fillPickers().catch(() => {}); |
| say(''); |
| } |
| |
| - function stopPreview() { |
| - if (!stream) return; |
| - for (const track of stream.getTracks()) track.stop(); |
| - stream = null; |
| + function errorSentence(err) { |
| + if (err.name === 'NotAllowedError') { |
| + return 'Meet needs permission to use your camera and microphone. You can still join with neither.'; |
| + } |
| + if (err.name === 'NotReadableError') { |
| + return 'Your camera or microphone is already in use by another app. You can still join with neither.'; |
| + } |
| + return 'Meet could not reach a camera or microphone. You can still join with neither.'; |
| } |
| |
| // --- pickers: (re)built from enumerateDevices, which only carries --- |
| @@ -101,7 +188,14 @@ |
| // --- this always runs after a getUserMedia attempt, never before. --- |
| |
| async function fillPickers() { |
| - const devices = await navigator.mediaDevices.enumerateDevices(); |
| + let devices; |
| + try { |
| + devices = await navigator.mediaDevices.enumerateDevices(); |
| + } catch { |
| + // Treated as "no devices found" below, which hides every row |
| + // rather than leaving stale or half-filled selects on screen. |
| + devices = []; |
| + } |
| const fillOne = (select, kind, chosen) => { |
| select.replaceChildren(); |
| let count = 0; |
| @@ -116,12 +210,17 @@ |
| } |
| return count; |
| }; |
| - fillOne(pickCamera, 'videoinput', prefs.camera || currentDeviceId('video')); |
| - fillOne(pickMic, 'audioinput', prefs.mic || currentDeviceId('audio')); |
| - if (typeof HTMLMediaElement.prototype.setSinkId === 'function' && |
| - fillOne(pickSpeaker, 'audiooutput', prefs.speaker) > 0) { |
| - speakerRow.hidden = false; |
| - } |
| + const cameraCount = fillOne(pickCamera, 'videoinput', prefs.camera || currentDeviceId('video')); |
| + const micCount = fillOne(pickMic, 'audioinput', prefs.mic || currentDeviceId('audio')); |
| + const hasSinkId = typeof HTMLMediaElement.prototype.setSinkId === 'function'; |
| + const speakerCount = hasSinkId ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0; |
| + // A field with nothing to choose from is not help, it's clutter — |
| + // hidden rather than shown empty. Re-evaluated on every call, so a |
| + // device unplugged or plugged in mid-call (devicechange) is |
| + // reflected immediately. |
| + cameraRow.hidden = cameraCount === 0; |
| + micRow.hidden = micCount === 0; |
| + speakerRow.hidden = !hasSinkId || speakerCount === 0; |
| } |
| |
| function labelFor(kind, count) { |
| @@ -133,22 +232,7 @@ |
| return track ? track.getSettings().deviceId : ''; |
| } |
| |
| - // --- mute and camera-off defaults: applied to the live tracks when --- |
| - // --- there is a stream, always reflected on the toggle buttons --- |
| - // --- even when there isn't one, so the choice still holds at join. --- |
| - |
| - function applyToggles() { |
| - if (stream) { |
| - for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted; |
| - for (const track of stream.getVideoTracks()) track.enabled = !prefs.cameraOff; |
| - } |
| - muteButton.setAttribute('aria-pressed', String(!!prefs.muted)); |
| - muteButton.textContent = prefs.muted ? 'Unmute' : 'Mute'; |
| - cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff)); |
| - cameraButton.textContent = prefs.cameraOff ? 'Camera on' : 'Camera off'; |
| - } |
| - |
| - // --- wiring --- |
| + // --- wiring that needs media support --- |
| |
| pickCamera.addEventListener('change', () => { |
| prefs.camera = pickCamera.value; |
| @@ -164,22 +248,21 @@ |
| prefs.speaker = pickSpeaker.value; |
| savePrefs(); |
| }); |
| - muteButton.addEventListener('click', () => { |
| - prefs.muted = !prefs.muted; |
| - savePrefs(); |
| - applyToggles(); |
| - }); |
| - cameraButton.addEventListener('click', () => { |
| - prefs.cameraOff = !prefs.cameraOff; |
| - savePrefs(); |
| - applyToggles(); |
| - }); |
| - joinButton.addEventListener('click', () => { |
| - stopPreview(); |
| - location.assign(root.dataset.joinHref); |
| + navigator.mediaDevices.addEventListener?.('devicechange', () => { |
| + fillPickers().catch(() => {}); |
| }); |
| - navigator.mediaDevices.addEventListener?.('devicechange', fillPickers); |
| - window.addEventListener('pagehide', stopPreview); |
| |
| - startPreview(); |
| + // A tab loaded in the background is not somewhere a permission |
| + // prompt should appear unannounced; the request waits for the tab |
| + // to actually become visible, once, and proceeds normally from |
| + // there. |
| + if (document.visibilityState === 'visible') { |
| + startPreview(); |
| + } else { |
| + document.addEventListener('visibilitychange', function onVisible() { |
| + if (document.visibilityState !== 'visible') return; |
| + document.removeEventListener('visibilitychange', onVisible); |
| + startPreview(); |
| + }); |
| + } |
| })(); |