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.
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.
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
| State | What happens on getUserMedia | How to recover |
|---|---|---|
prompt | Browser asks the user | Wait for the choice |
granted | Resolves without a prompt | Nothing needed |
denied | Rejects with NotAllowedError | User 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.
| Keyword | Meaning | On failure |
|---|---|---|
min | Hard floor | Rejects with OverconstrainedError |
max | Hard ceiling | Rejects with OverconstrainedError |
exact | Must equal this value | Rejects with OverconstrainedError |
ideal | Preferred, best effort | Picks the closest available value, never rejects |
| bare number | Treated as ideal | Best 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
| Property | Type | Purpose |
|---|---|---|
width | ConstrainULong | Frame width in pixels |
height | ConstrainULong | Frame height in pixels |
frameRate | ConstrainDouble | Frames per second |
aspectRatio | ConstrainDouble | width / height, e.g. 1.7777 for 16:9 |
facingMode | ConstrainDOMString | user, environment, left, right |
deviceId | ConstrainDOMString | Pick a specific camera |
resizeMode | ConstrainDOMString | none 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.
| Property | Type | Purpose |
|---|---|---|
echoCancellation | ConstrainBoolean | Removes the remote audio leaking back through the mic |
noiseSuppression | ConstrainBoolean | Attenuates steady background noise |
autoGainControl | ConstrainBoolean | Normalizes input level |
sampleRate | ConstrainULong | Samples per second |
channelCount | ConstrainULong | 1 mono, 2 stereo |
deviceId | ConstrainDOMString | Pick 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:
- Collect every capture setting the matching devices can produce.
- Discard any candidate that violates a mandatory constraint (
min,max,exact). If nothing survives, reject withOverconstrainedError. - Among survivors, pick the one closest to your
idealvalues, weighted across all properties. - 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);
}
| kind | Meaning |
|---|---|
videoinput | Camera |
audioinput | Microphone |
audiooutput | Speaker 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.
| Signal | Set by | Meaning |
|---|---|---|
track.enabled | You | Whether you forward this track's data |
track.muted | Platform, read-only | Source is temporarily not delivering data |
track.readyState | Platform, read-only | live 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.
| name | Cause | Response |
|---|---|---|
NotAllowedError | User denied, or permission already denied for the origin | Show how to enable in site settings; do not loop on re-requesting |
NotFoundError | No device matches the kind requested (no camera at all) | Tell the user no camera/mic was found |
OverconstrainedError | A mandatory constraint cannot be met | Relax constraints and retry; err.constraint names the offender |
NotReadableError | Hardware found but the OS or another app holds it | Ask the user to close the app using the camera |
AbortError | Hardware failed for another reason | Generic retry or report |
SecurityError | Capture disabled by configuration or policy | Not recoverable in-page |
TypeError | Both audio and video false, or malformed constraints | Fix 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.
| Action | Device open? | Light on? | Reversible? |
|---|---|---|---|
track.enabled = false | Yes | Yes | Yes, set back to true |
track.stop() | No | No | No, 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
- Video and audio tracks: sending captured tracks over a peer connection and the
replaceTrackhot-swap. - Audio capture and processing: the audio constraint flags and what they do to the signal.
- Group calls: fanning local capture out to many peers, host-side.
- MediaStream reference: the container API in full.
- MediaStreamTrack reference: track lifecycle, settings, and capabilities.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
navigator.mediaDevices is undefined | Page served over plain HTTP | Use HTTPS or localhost |
| No prompt, immediate rejection | Origin already denied | Detect NotAllowedError, point user to site settings |
| Device labels are empty strings | No grant yet | Call getUserMedia once before enumerateDevices |
| Camera light stays on | Tracks not stopped | Call track.stop() on every track |
| Requested resolution ignored | Used ideal, hardware capped | Read back with getSettings(); the request was best-effort |
OverconstrainedError on a good device | A mandatory constraint cannot be met | Check err.constraint, relax or drop it, retry |
NotReadableError | Another app holds the camera | Close the other app; the OS allows one consumer |
| Mute button kills the call | Used stop() instead of enabled = false | Use enabled to mute reversibly |