Skip to content
Blog

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.

Sources