Camera Capture on Flutter Web: getUserMedia, Preview, and Snapshots
Open the camera in a Flutter web app the browser's way: secure contexts, Permissions-Policy, getUserMedia constraints and errors, HtmlElementView preview, canvas snapshots.
Published on • November 11, 2026
AI Assistant

Camera capture on Flutter web behaves like the microphone with a viewfinder attached: the same getUserMedia call, the same permission prompts, and the same policy headers, plus device choice, a live preview, and a snapshot pipeline that mobile camera plugins hide from you. This post follows the browser rules first, then wires them into Flutter with package:web.
The browser sets the rules
getUserMedia exists only in secure contexts: over HTTPS or on localhost, navigator.mediaDevices is defined; anywhere else it is undefined and the call throws a TypeError before any permission UI appears. Fire the request from a real user gesture as a rule: mobile browsers and embedded frames are least forgiving when the first capture attempt arrives with no interaction behind it, and the prompt should feel like a direct response to a tap.
Permissions Policy can veto you at the document level. The camera directive governs video input, so Permissions-Policy: camera=() disables the webcam for the whole page and its frames, while camera=(self) is the sane default; embedded apps additionally need allow="camera" on the <iframe>. When the call rejects, map it by name:
import 'dart:js_util' as jutil;
const _cameraErrors = {
'NotAllowedError': 'denied', // denied, insecure context, or policy
'NotFoundError': 'no-device', // no camera attached
'OverconstrainedError': 'unavailable', // facingMode/resolution impossible
'NotReadableError': 'busy', // device held by another app or tab
};
String cameraErrorKind(Object error) =>
_cameraErrors[jutil.getProperty<String?>(error, 'name')] ?? 'unknown';
OverconstrainedError deserves a fallback rather than an error page: catch it and retry with looser constraints, because phones frequently fail an exact rear-camera request while a plain ideal preference would have succeeded.
Opening the camera with package:web
With dart:html retired, the supported route is package:web (^1.1.2) plus dart:js_interop:
import 'dart:js_interop';
import 'package:web/web.dart' as web;
web.MediaStream? _stream;
Future<void> openCamera({String? deviceId}) async {
final video = web.MediaTrackConstraints(
facingMode: deviceId == null ? 'environment'.toJS : null,
deviceId: deviceId?.toJS,
width: 1280.toJS, // plain values are "ideal" preferences
);
try {
_stream = await _getStream(video);
} catch (e) {
if (cameraErrorKind(e) != 'unavailable') rethrow; // OverconstrainedError: retry loose
_stream = await _getStream(web.MediaTrackConstraints());
}
}
Future<web.MediaStream> _getStream(web.MediaTrackConstraints video) =>
web.window.navigator.mediaDevices
.getUserMedia(web.MediaStreamConstraints(video: video))
.toDart;
facingMode: 'environment' selects the rear camera on phones, 'user' the selfie camera, and switching between them means stopping the current tracks first — some browsers ignore a new facing mode while the old one is still live.
Preview and snapshots in the widget tree
A browser camera renders into a <video> element, so put one in the tree with HtmlElementView (Flutter web only). Register the factory outside build and keep the element for capture:
import 'dart:ui_web' as ui_web;
import 'package:flutter/widgets.dart';
web.HTMLVideoElement? _video;
void registerPreview() {
ui_web.platformViewRegistry.registerViewFactory('camera-preview', (int viewId) {
_video = web.HTMLVideoElement()
..autoplay = true
..muted = true
..style.setProperty('object-fit', 'cover');
return _video!;
});
}
Mount it with const HtmlElementView(viewType: 'camera-preview'), then attach the stream — video.srcObject = _stream — and the preview goes live. Taking a photo means copying one frame to a canvas:
Future<String> snapshotJpeg() async {
final video = _video!;
final canvas = web.HTMLCanvasElement()
..width = video.videoWidth
..height = video.videoHeight;
final ctx = canvas.getContext('2d') as web.CanvasRenderingContext2D;
ctx.drawImage(video, 0, 0);
return canvas.toDataURL('image/jpeg', 0.92); // or toBlob() for lower memory
}
The data URL drops straight into Flutter: decode it with Uri.parse(src).data and pass the bytes to Image.memory.
Choosing a camera after permission
Device labels stay hidden until permission is granted, so enumerate once the stream is open:
Future<List<web.MediaDeviceInfo>> cameras() async {
final all = await web.window.navigator.mediaDevices.enumerateDevices().toDart;
return all.toDart.where((d) => d.kind == 'videoinput').toList();
}
Feed a selected deviceId back into openCamera() to switch cameras — with a deviceId set, the facingMode preference is skipped entirely.
camera_web, and releasing the camera
If you would rather not hand-roll this, the endorsed camera plugin works on web through camera_web (0.3.6): add camera, and the web implementation is pulled in automatically. It is honest about its state — marked under development, with exposure, focus, sensor orientation, image format, and frame streaming still missing — and captures come back as blob URLs you display with Image.network or Image.memory, never Image.file; for custom pipelines, raw package:web stays the more predictable path. Either way, release the hardware when the screen goes away, or the browser’s recording indicator stays lit:
Future<void> closeCamera() async {
final stream = _stream;
if (stream == null) return;
for (final track in stream.getTracks().toDart) {
track.stop();
}
_stream = null;
_video?.srcObject = null;
}
Takeaway: Treat the camera as a browser feature — HTTPS, a gesture-driven getUserMedia, policy headers, and relaxed constraints — then mount the stream in an HtmlElementView, snapshot it with a canvas, and always stop the tracks so the indicator light goes out.