Media Capture with getUserMedia

The problem

A real-time call needs pixels and samples from local hardware. The browser sits between your code and the operating system's camera and microphone. It will not hand over that hardware on request alone. The user must agree, the page must be trusted, and the requested format must match what the device can produce.

navigator.mediaDevices.getUserMedia() is the entry point. You describe the media you want, the browser prompts the user, negotiates with the hardware, and resolves with a live MediaStream. That stream feeds a <video> element for preview and feeds the peer connection for transmission.

This page covers capture only: how to open the camera and microphone, how to describe what you want, how to pick a specific device, how to read what you actually got, how every error surfaces, and how to release the hardware when you finish. Sending the result to a peer is covered in tracks; audio-specific processing in audio; many-to-many fan-out in group.

Capture is the first step of any media call and the one most likely to fail in ways outside your control. The user can refuse. The device can be busy. The requested format can exceed the hardware. A correct capture layer treats each of these as an expected outcome with a defined response, not an exception to log and forget. The rest of the call depends on getting a live, correctly configured stream, so the care spent here pays off downstream.

Capture is also where privacy lives. The browser guards camera and microphone access more tightly than almost any other API because the cost of a mistake is a recording the user never consented to. The constraints, the prompts, the hidden device labels, the indicator light: every one is a deliberate barrier. Working with capture means working with those barriers, not around them.

Page code navigator.mediaDevices .getUserMedia(constraints) User grants permission? OS camera / microphone negotiates format MediaStream (live) <video> preview Peer connection no → reject yes

The shape of the API

getUserMedia takes one argument, a MediaStreamConstraints object, and returns a Promise.

const stream = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: true,
});

const video = document.querySelector('video');
video.srcObject = stream;
await video.play();

The promise resolves with a MediaStream once the user grants permission and the hardware starts. It rejects if the user denies, no device matches, or the device cannot be opened. Every failure is an error you must handle; none of them are optional in production.

navigator.mediaDevices is the modern surface. The old navigator.getUserMedia callback form is removed from current browsers. Use the promise-based mediaDevices form only.

One concept first

A MediaStream is a container. It holds zero or more MediaStreamTrack objects, each a single source: one camera, one microphone. getUserMedia({ audio: true, video: true }) returns one stream holding two tracks. You mute, stop, and inspect tracks individually.

Secure context and the permission prompt

getUserMedia exists only in a secure context. That means HTTPS or localhost. On plain http:// over the network, navigator.mediaDevices is undefined and any call throws. This is a hard rule, not a setting.

if (!navigator.mediaDevices?.getUserMedia) {
  throw new Error('getUserMedia unavailable: needs HTTPS or localhost');
}

When you call getUserMedia, the browser shows a permission prompt the first time a page requests a given kind of device. The user can allow, deny, or dismiss. The choice is remembered per origin in most browsers, so a second call may resolve with no prompt. A denied origin keeps rejecting until the user clears the setting.

The prompt only appears in response to your call. There is no way to query "will this prompt?" ahead of time for the legacy path. The Permissions API gives a hint:

const status = await navigator.permissions.query({ name: 'camera' });
// status.state is 'granted', 'denied', or 'prompt'
status.onchange = () => console.log('camera permission ->', status.state);

camera and microphone are separate permissions. Granting one does not grant the other. Browser support for querying them varies, so treat the result as advisory and still handle a rejection from getUserMedia.

Permission lifecycle

StateWhat happens on getUserMediaHow to recover
promptBrowser asks the userWait for the choice
grantedResolves without a promptNothing needed
deniedRejects with NotAllowedErrorUser must change the site setting; you cannot re-prompt

Once an origin is denied, calling getUserMedia again will not show the prompt. Detect NotAllowedError and show instructions pointing the user to the browser's site settings.

Transient activation

Some browsers require a user gesture before they show the prompt. A getUserMedia call fired on page load, with no click or keypress behind it, can be rejected or silently ignored. Tie the request to a button.

startButton.addEventListener('click', async () => {
  const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  // ...
});

This also makes the prompt comprehensible. A user who just clicked "Start camera" understands why the browser is asking. A prompt that appears before any interaction reads as a dark pattern and gets denied.

Permission is per origin, not per page

The grant attaches to the origin: scheme, host, and port together. Every page on that origin shares it. A grant on https://example.com/a applies to https://example.com/b. Change the port or scheme and it is a different origin with its own permission state. This is why localhost and your deployed HTTPS host do not share grants during development.

MediaStreamConstraints in full

The constraints object has two top-level keys: audio and video. Each accepts either a boolean or a MediaTrackConstraints object.

// Booleans: "give me one, any settings"
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });

// Objects: "give me one that satisfies these"
await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true },
  video: { width: 1280, height: 720 },
});

false or omitting a key means "do not capture this kind". At least one of audio or video must be truthy, or the call rejects with TypeError.

A boolean true is equivalent to an empty constraint object: capture a track with whatever default settings the browser picks. The object form is where you express preferences and requirements.

ConstrainULong and ConstrainDouble

Numeric video constraints (width, height, frameRate, aspectRatio) accept a bare number or an object with exact, ideal, min, max.

video: {
  width:  { min: 640, ideal: 1280, max: 1920 },
  height: { min: 480, ideal: 720,  max: 1080 },
  frameRate: { ideal: 30, max: 60 },
}

The keywords differ in how strictly the browser treats them.

KeywordMeaningOn failure
minHard floorRejects with OverconstrainedError
maxHard ceilingRejects with OverconstrainedError
exactMust equal this valueRejects with OverconstrainedError
idealPreferred, best effortPicks the closest available value, never rejects
bare numberTreated as idealBest effort

min, max, and exact are mandatory. If the device cannot meet them, the call fails. ideal is the safe default for most apps: ask for { ideal: 1280 } and accept whatever the hardware can do near that. Reserve exact for cases where a wrong value is useless, such as facingMode: { exact: 'environment' } on a phone where the front camera would defeat the purpose.

Video constraints

PropertyTypePurpose
widthConstrainULongFrame width in pixels
heightConstrainULongFrame height in pixels
frameRateConstrainDoubleFrames per second
aspectRatioConstrainDoublewidth / height, e.g. 1.7777 for 16:9
facingModeConstrainDOMStringuser, environment, left, right
deviceIdConstrainDOMStringPick a specific camera
resizeModeConstrainDOMStringnone or crop-and-scale

The browser does not crop the camera sensor to match arbitrary width and height. It picks the closest native capture mode, then may downscale. Asking for 1920x1080 on a 720p webcam yields 720p, not an upscaled fake. Read back what you got with getSettings(), never assume the request was honored.

aspectRatio and explicit width/height can conflict. Provide the dimensions you care about and let the browser derive the rest, or set aspectRatio alone and one dimension.

facingMode

facingMode chooses front or rear camera on devices that have both, which means phones and tablets. Desktops typically expose one camera and ignore it.

// Selfie camera, preferred but not required
video: { facingMode: 'user' }

// Rear camera, required (fail rather than fall back to front)
video: { facingMode: { exact: 'environment' } }

'user' faces the user. 'environment' faces away. As a bare string it is an ideal hint; wrap it in exact to force it.

A document scanner or barcode reader wants the rear camera and should fail rather than capture the user's face. A video call wants the front camera but can tolerate the rear one. Match the strictness to the consequence of getting it wrong.

'left' and 'right' exist in the spec for devices with multiple environment-facing cameras. They are rare. Treat them as best-effort and never depend on them.

Audio constraints

Audio defaults are tuned for voice calls. The processing flags are on by default in most browsers because conferencing is the common case.

PropertyTypePurpose
echoCancellationConstrainBooleanRemoves the remote audio leaking back through the mic
noiseSuppressionConstrainBooleanAttenuates steady background noise
autoGainControlConstrainBooleanNormalizes input level
sampleRateConstrainULongSamples per second
channelCountConstrainULong1 mono, 2 stereo
deviceIdConstrainDOMStringPick a specific microphone

For music or any non-voice capture, turn the voice processing off so it does not distort the source.

audio: {
  echoCancellation: false,
  noiseSuppression: false,
  autoGainControl: false,
  channelCount: 2,
}

Audio constraint handling is covered further in audio.

getSupportedConstraints

Different browsers and devices support different constraint properties. Before you build a constraint object around an exotic property, ask what the platform recognizes.

const supported = navigator.mediaDevices.getSupportedConstraints();
// { width: true, height: true, facingMode: true, ... }

if (supported.facingMode) {
  // safe to request facingMode
}

This returns a flat object of property names mapped to true. A property absent or false is silently ignored when you pass it, so it will not cause a rejection, but it will also do nothing. Check support before relying on behavior.

The silent-ignore rule has a sharp edge. Pass an unsupported property as a mandatory exact constraint and the browser does not reject; it drops the constraint and gives you whatever it likes. Your "required" setting becomes a no-op. getSupportedConstraints is how you avoid building logic on a property the platform will quietly discard.

How the browser picks a result

Understanding the selection algorithm explains why a request succeeds with surprising values. The browser does not run your constraints as a filter that either matches or fails. It scores candidates.

The process, in order:

  1. Collect every capture setting the matching devices can produce.
  2. Discard any candidate that violates a mandatory constraint (min, max, exact). If nothing survives, reject with OverconstrainedError.
  3. Among survivors, pick the one closest to your ideal values, weighted across all properties.
  4. Apply that setting and resolve.

Mandatory constraints prune the candidate set. Ideal constraints rank what remains. This is why { width: { ideal: 4000 } } on a 1080p camera resolves at 1080: nothing was mandatory, so the closest available width won. And why { width: { min: 4000 } } on the same camera rejects: the floor pruned every candidate.

The weighting across properties is the fuzzy part. If you set ideal width and ideal frame rate and the device cannot hit both, the browser trades one against the other by its own metric. You cannot control that trade directly. If a property must win, make it mandatory and accept the rejection risk.

Enumerating devices

enumerateDevices() lists the audio and video inputs and outputs the browser knows about. Each entry is a MediaDeviceInfo with deviceId, kind, label, and groupId.

const devices = await navigator.mediaDevices.enumerateDevices();
for (const d of devices) {
  console.log(d.kind, d.label || '(label hidden)', d.deviceId);
}
kindMeaning
videoinputCamera
audioinputMicrophone
audiooutputSpeaker or headphone

Why labels are empty before permission

label is a privacy-protected field. Before the user grants permission to any device of that kind, enumerateDevices still lists the devices, including their deviceId, but label is an empty string. A list of camera names is itself identifying information, so the browser withholds it until you have an active grant.

The practical consequence: you cannot build a friendly camera picker on first load. The flow is call getUserMedia once to obtain permission, then call enumerateDevices to get populated labels.

// First, get permission with a minimal request
const stream = await navigator.mediaDevices.getUserMedia({ video: true });

// Now labels are populated
const cameras = (await navigator.mediaDevices.enumerateDevices())
  .filter(d => d.kind === 'videoinput');

cameras.forEach(c => console.log(c.label, c.deviceId)); // labels now present

deviceId is stable per origin while permission holds. It can rotate when the user clears site data, so do not persist it as a permanent identifier across sessions.

The devicechange event

Devices come and go: a USB webcam is unplugged, a Bluetooth headset connects. mediaDevices fires devicechange so you can refresh your list.

navigator.mediaDevices.addEventListener('devicechange', async () => {
  const devices = await navigator.mediaDevices.enumerateDevices();
  rebuildDevicePicker(devices);
});

The event carries no detail about what changed. Re-enumerate and diff against your last known list if you need specifics. This matters mid-call: if the active microphone disappears, the track ends and you must switch to another device.

Reading and changing track settings

Constraints are what you asked for. They are not what you got. After the stream resolves, three methods on each MediaStreamTrack tell you the truth and let you adjust it. See MediaStreamTrack for the full surface.

getSettings

getSettings() returns the current actual values for the track.

const [track] = stream.getVideoTracks();
const s = track.getSettings();
console.log(s.width, s.height, s.frameRate, s.deviceId, s.facingMode);
// e.g. 1280 720 30 "abc..." "user"

Use this to update your UI with the resolution you actually have, and to learn which deviceId the browser chose when you did not specify one.

getCapabilities

getCapabilities() returns the full range the hardware can produce for this track: min and max for ranges, arrays of allowed strings for enumerations.

const caps = track.getCapabilities();
// { width: { min: 1, max: 1920 }, frameRate: { min: 1, max: 30 },
//   facingMode: ['user', 'environment'], ... }

This is how you build a resolution slider or a facing-mode toggle bounded to what the device supports. Some browsers return an empty object when capabilities are unknown, so guard against missing fields.

applyConstraints

applyConstraints() re-negotiates an already-running track without reopening the device or touching the peer connection. It returns a promise that rejects with OverconstrainedError if the values cannot be met.

const track = stream.getVideoTracks()[0];
try {
  await track.applyConstraints({
    width:     { ideal: 640 },
    height:    { ideal: 480 },
    frameRate: { ideal: 15 },
  });
} catch (err) {
  console.error('could not apply', err.name); // OverconstrainedError
}

This is the right tool for changing resolution or frame rate during a call, for example to drop quality on a congested network. It is cheaper than stopping and reopening.

applyConstraints does not change which physical device the track uses. It re-negotiates the format of the current source. To switch to a different camera you must capture a new track; see the device-switching section below.

A constraint-negotiation example

Put getCapabilities, getSettings, and applyConstraints together to negotiate the best format a device can offer without guessing or risking an OverconstrainedError.

The strategy: ask the hardware what it can do, choose a target inside that range, then apply it. Because you stay inside the reported capabilities, the apply cannot fail on those properties.

async function negotiateBest(track, targetWidth) {
  const caps = track.getCapabilities();

  // No capability data: fall back to a plain ideal request
  if (!caps.width) {
    await track.applyConstraints({ width: { ideal: targetWidth } });
    return track.getSettings();
  }

  // Clamp the target into the supported range
  const width = Math.min(caps.width.max, Math.max(caps.width.min, targetWidth));

  // Prefer the highest frame rate the device reports
  const frameRate = caps.frameRate ? caps.frameRate.max : undefined;

  await track.applyConstraints({
    width:     { ideal: width },
    frameRate: frameRate ? { ideal: frameRate } : undefined,
  });

  return track.getSettings(); // the actual result, post-negotiation
}

Two passes give a reliable result. First open the device with loose constraints so the call cannot fail. Then read capabilities and tighten.

// Pass 1: open with minimal demands
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];

// Pass 2: negotiate up to the best supported format
const settings = await negotiateBest(track, 1920);
console.log('negotiated', settings.width, '@', settings.frameRate);

This pattern avoids the common trap of demanding a resolution in the initial getUserMedia call, getting OverconstrainedError on a weaker device, and having to retry. Open loose, then negotiate against known capabilities.

Selecting and switching a device

To open one specific camera or microphone, pass its deviceId.

async function openCamera(deviceId) {
  return navigator.mediaDevices.getUserMedia({
    video: deviceId
      ? { deviceId: { exact: deviceId } }   // this exact camera, or fail
      : { facingMode: 'user' },             // sensible default
  });
}

Use { exact: deviceId } when the user explicitly picked a device, so a missing device fails loudly rather than silently opening a different one. Use a bare deviceId as an ideal hint when you have a remembered preference but a fallback is acceptable.

Switching mid-call has two parts: open the new device, then stop the old one. Open first, so a failure leaves the existing track running.

async function switchCamera(oldStream, deviceId) {
  const next = await navigator.mediaDevices.getUserMedia({
    video: { deviceId: { exact: deviceId } },
  });
  oldStream.getVideoTracks().forEach(t => t.stop()); // release old camera
  return next;
}

In a call, you replace the track on the sender rather than restarting the connection. That technique lives in tracks; here the focus is acquiring the new track cleanly.

Toggling front and rear without a deviceId

On mobile you often do not have a meaningful deviceId to toggle between. Switch by facingMode instead, reading the current mode to decide the next.

async function flipCamera(currentStream) {
  const current = currentStream.getVideoTracks()[0].getSettings().facingMode;
  const next = current === 'environment' ? 'user' : 'environment';

  const stream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: { exact: next } },
  });
  currentStream.getVideoTracks().forEach(t => t.stop());
  return stream;
}

Use exact here: a flip that silently lands on the same camera is a bug the user will notice. If exact rejects, the device has only one camera and there is nothing to flip to.

Track events

A MediaStreamTrack is not static. The OS can end it without your code asking. Listen for that.

The ended event fires when the track stops producing data for a reason outside your control: the device was unplugged, the OS revoked access, or another app seized exclusive control. This is distinct from you calling stop(), which does not fire ended.

track.addEventListener('ended', () => {
  console.warn('track ended unexpectedly:', track.kind);
  promptToReconnectDevice();
});

The mute and unmute events describe the source temporarily ceasing or resuming data, for example a phone call interrupting the microphone. A muted track is still live; it just emits nothing. Do not confuse this platform-driven muted with the enabled flag you set for a mute button.

SignalSet byMeaning
track.enabledYouWhether you forward this track's data
track.mutedPlatform, read-onlySource is temporarily not delivering data
track.readyStatePlatform, read-onlylive or ended

Handle ended in any app that runs longer than a few seconds. A USB camera unplugged mid-call ends its track; without a listener your preview freezes on the last frame with no explanation.

Error handling

getUserMedia rejects with a DOMException whose name identifies the cause. Branch on name, never on the message string, which is not stable across browsers.

nameCauseResponse
NotAllowedErrorUser denied, or permission already denied for the originShow how to enable in site settings; do not loop on re-requesting
NotFoundErrorNo device matches the kind requested (no camera at all)Tell the user no camera/mic was found
OverconstrainedErrorA mandatory constraint cannot be metRelax constraints and retry; err.constraint names the offender
NotReadableErrorHardware found but the OS or another app holds itAsk the user to close the app using the camera
AbortErrorHardware failed for another reasonGeneric retry or report
SecurityErrorCapture disabled by configuration or policyNot recoverable in-page
TypeErrorBoth audio and video false, or malformed constraintsFix the constraints object
try {
  stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
} catch (err) {
  switch (err.name) {
    case 'NotAllowedError':
      showMessage('Camera access was blocked. Enable it in the address bar.');
      break;
    case 'NotFoundError':
      showMessage('No camera or microphone found.');
      break;
    case 'NotReadableError':
      showMessage('Your camera is in use by another application.');
      break;
    case 'OverconstrainedError':
      console.warn('Unmet constraint:', err.constraint);
      stream = await navigator.mediaDevices.getUserMedia({ video: true }); // retry relaxed
      break;
    default:
      showMessage('Could not start media: ' + err.name);
  }
}

OverconstrainedError carries an extra constraint property naming the property that failed. That is the signal to drop or loosen that one constraint and retry, rather than giving up.

Stopping tracks and the camera light

A MediaStream does not release the hardware when it goes out of scope or when you clear video.srcObject. The camera light stays on until you stop every track. Garbage collection is not a substitute. Calling stop() is the only way to release the device.

function stopStream(stream) {
  stream.getTracks().forEach(track => track.stop());
}

stop() ends the track permanently. Its readyState becomes ended, the device releases, and the indicator light turns off. A stopped track cannot restart; capture a fresh stream to resume.

Call stop() on the actual tracks, not on a copy you forgot about. A common leak: opening a temporary stream to populate enumerateDevices labels, then never stopping it, leaving the light on.

// Get permission for labels, then release immediately
const probe = await navigator.mediaDevices.getUserMedia({ video: true });
const devices = await navigator.mediaDevices.enumerateDevices();
probe.getTracks().forEach(t => t.stop()); // release before building the picker

Stop on page unload, on call end, and whenever you swap a stream for a new one. Track a single reference to the active stream and stop it before replacing.

Muting versus stopping

Setting track.enabled = false mutes the track: it keeps the device open but feeds black frames or silence. This is reversible and instant, the right choice for a mute button. stop() is final and releases hardware. Pick by intent.

ActionDevice open?Light on?Reversible?
track.enabled = falseYesYesYes, set back to true
track.stop()NoNoNo, must re-capture

Audio-only and video-only capture

Request only what you need. A voice call has no business opening the camera, and the extra prompt erodes trust.

// Microphone only: no camera prompt, no camera light
const audio = await navigator.mediaDevices.getUserMedia({ audio: true });

// Camera only: silent video, e.g. a background preview
const video = await navigator.mediaDevices.getUserMedia({ video: true });

A video-only stream has no audio track. stream.getAudioTracks() returns an empty array. Code that assumes both kinds exist will break; query the arrays before indexing.

const [audioTrack] = stream.getAudioTracks(); // may be undefined
if (audioTrack) audioTrack.enabled = muted ? false : true;

You can also split a request into two calls when audio and video have independent lifecycles, for example a voice call that adds video later.

const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// ...later, when the user turns on their camera...
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true });

Each call is a separate permission and a separate device open. The trade-off is two prompts on first use instead of one. Combine them in a single call when both are needed up front; split them when the camera is optional or deferred.

Combining tracks into one stream

A MediaStream you build yourself can hold tracks from different sources. Add and remove tracks to assemble exactly the stream you want to send.

const combined = new MediaStream();
combined.addTrack(audioStream.getAudioTracks()[0]);
combined.addTrack(videoStream.getVideoTracks()[0]);

addTrack and removeTrack only manipulate the container. They do not open or close devices. Stopping the original track still releases its hardware regardless of which streams reference it. A track can belong to several streams at once; it is one source with multiple labels pointing at it.

A complete capture-then-preview flow

The pieces together: feature-detect, request, handle errors, preview, populate the device picker, and clean up.

let activeStream = null;

async function startPreview(videoEl, deviceId) {
  if (!navigator.mediaDevices?.getUserMedia) {
    throw new Error('Media capture needs HTTPS or localhost');
  }

  // Open the new stream before discarding the old one
  const constraints = {
    audio: { echoCancellation: true },
    video: deviceId
      ? { deviceId: { exact: deviceId } }
      : { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
  };

  let stream;
  try {
    stream = await navigator.mediaDevices.getUserMedia(constraints);
  } catch (err) {
    if (err.name === 'OverconstrainedError') {
      // Fall back to defaults rather than fail
      stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
    } else {
      throw err;
    }
  }

  // Swap: release the previous device only after the new one is live
  if (activeStream) activeStream.getTracks().forEach(t => t.stop());
  activeStream = stream;

  videoEl.srcObject = stream;
  await videoEl.play();

  const settings = stream.getVideoTracks()[0].getSettings();
  console.log(`capturing ${settings.width}x${settings.height} @ ${settings.frameRate}fps`);

  return stream;
}

async function listCameras() {
  // Labels are only populated after a grant exists
  const devices = await navigator.mediaDevices.enumerateDevices();
  return devices.filter(d => d.kind === 'videoinput');
}

function stopPreview() {
  if (!activeStream) return;
  activeStream.getTracks().forEach(t => t.stop());
  activeStream = null;
}

// Refresh the picker when hardware changes
navigator.mediaDevices.addEventListener('devicechange', async () => {
  const cameras = await listCameras();
  renderPicker(cameras);
});

// Always release on unload
window.addEventListener('pagehide', stopPreview);

The order matters. Open the new stream first, then stop the old one, so a denial or hardware failure leaves the current preview intact. Read getSettings() after the stream is live to show the real resolution. Stop on pagehide so the camera light never lingers after navigation.

A working, inspectable version of this flow runs at the camera demo.

Recap

getUserMedia opens local hardware and resolves with a MediaStream of one track per source. It works only in a secure context and only after the user grants permission per origin.

Constraints describe what you want. ideal and bare values are best-effort and never reject. min, max, and exact are mandatory and reject with OverconstrainedError when unmet. After the stream is live, getSettings() tells you what you actually got, getCapabilities() tells you the range available, and applyConstraints() re-negotiates a running track in place.

enumerateDevices lists hardware but hides labels until a grant exists, so prompt first, enumerate second. devicechange fires when hardware appears or disappears. Branch error handling on err.name. Release hardware with track.stop(); nothing else turns the camera light off.

Going further

Troubleshooting

SymptomLikely causeFix
navigator.mediaDevices is undefinedPage served over plain HTTPUse HTTPS or localhost
No prompt, immediate rejectionOrigin already deniedDetect NotAllowedError, point user to site settings
Device labels are empty stringsNo grant yetCall getUserMedia once before enumerateDevices
Camera light stays onTracks not stoppedCall track.stop() on every track
Requested resolution ignoredUsed ideal, hardware cappedRead back with getSettings(); the request was best-effort
OverconstrainedError on a good deviceA mandatory constraint cannot be metCheck err.constraint, relax or drop it, retry
NotReadableErrorAnother app holds the cameraClose the other app; the OS allows one consumer
Mute button kills the callUsed stop() instead of enabled = falseUse enabled to mute reversibly