How to Build a Lightweight Flutter Camera Plugin for Desktop, Mobile, and Web

The official Flutter camera plugin ships Android, iOS, and web implementations. Windows, Linux, and macOS have never been part of that list, so a barcode scanner that has to run on all six Flutter targets cannot be built on the official plugin alone. flutter_lite_camera closes the gap with one Dart API backed by five native capture pipelines and one browser implementation: every platform renders the preview as a native texture, and captureFrame() returns a single RGB888 frame on demand when an image processing SDK needs pixels.

The plugin is published as flutter_lite_camera 0.2.3 and supports Windows, Linux, macOS, iOS, Android, and web. If you only want the scanning result, flutter pub add flutter_lite_camera plus the usage snippet from the README is enough. The rest of this tutorial builds the plugin and explains what each backend has to do differently.

Flutter Multi-Barcode Scanner Demo

The recording above is the desktop build: the preview is a Flutter texture, and the Start/Stop buttons drive the native capture loop. The same lib/main.dart runs on Android, iOS, and the web without changes — only the preview widget differs, and buildPreview() selects it per platform.

Open the Flutter Camera Barcode Scanner demo to run this example in a browser: no install, camera permission only, and the hosted page activates without a license key.

Flutter camera preview app on macOS

Key Takeaways

  • One Dart API — getDeviceList, open, startPreview, buildPreview, captureFrame, setResolution, getRotation, release — works on all six Flutter targets. The official camera plugin covers Android, iOS, and web only.
  • The preview never crosses the platform channel: Windows and Linux push RGBA into a FlutterTexture, Apple platforms hand a BGRA CVPixelBuffer to FlutterTexture, Android uses an engine SurfaceTexture, and the web embeds the getUserMedia video element in a platform view.
  • captureFrame() returns one RGB888 buffer together with the real width and height. While a preview is running it reads a native frame cache, so decoding does not stall the video stream.
  • Resolution is negotiated, not dictated: setResolution() and the ResolutionPreset enum pick the closest mode the device supports, and getWidth()/getHeight() report what was actually selected.
  • Rotation is handled in Dart: getRotation() returns 0, 90, 180, or 270 to apply to the preview widget and to decoded coordinates. iOS phones typically report 90 in portrait, Android and desktop report 0.
  • Color handling is platform-specific: Apple backends read the pixel format the pipeline delivered, and Android exposes a byteOrder parameter because some HALs swap the chroma planes.

Prerequisites

  • Flutter 3.27 (Dart 3.6) or newer. Flutter 3.44 or newer is required when the host app enables Swift Package Manager for the iOS or macOS implementation.
  • Windows: Visual Studio 2022 with the Desktop development with C++ workload.
  • Linux: GTK 3 development headers plus the V4L2 kernel headers. v4l2loopback is handy for testing without a physical camera.
  • macOS / iOS: Xcode with a deployment target of macOS 10.11 or iOS 13.0.
  • Android: minSdk 24, Camera2 API.
  • A Dynamsoft Barcode Reader license for the decoding half of the demo: Get a 30-day free trial license.

One Plugin, Six Platforms

Platform Language Preview path captureFrame() source Permission handling
Windows C++ (Media Foundation) Native texture, RGBA pixel buffer RGB frame cache, direct read when idle —
Linux C++ (V4L2) Native texture, FlPixelBufferTexture RGB frame cache, direct read when idle —
macOS Swift (AVFoundation) Native texture, BGRA CVPixelBuffer Latest pixel buffer Entitlements + TCC prompt
iOS Swift (AVFoundation) Native texture, BGRA CVPixelBuffer Latest pixel buffer Info.plist + requestAccess
Android Kotlin (Camera2) Engine SurfaceTexture Latest YUV_420_888 frame, converted and rotated Manifest + runtime request
Web Dart (getUserMedia) Video element in a platform view Canvas snapshot Browser prompt (HTTPS)

Two rules shape the whole design. First, display and processing are separate paths: the preview is a GPU-side texture that Flutter composites, and captureFrame() is an on-demand pull that copies pixels into Dart. Second, the plugin never guesses a resolution: the default target is 640x480, each platform negotiates the closest supported mode, and the actual size is always readable.

Scaffolding a Cross-Platform Flutter Plugin

Run the following command to create a plugin project with platform folders for all six targets:

flutter create --org com.example --template=plugin \
  --platforms=android,ios,linux,macos,windows,web flutter_lite_camera

The generated tree splits along language lines. Windows and Linux get C++ folders, macOS and iOS get Swift folders, Android gets Kotlin, and web is Dart-only — the browser implementation lives in lib/flutter_lite_camera_web.dart and is registered through flutter_web_plugins, so there is no web/ directory in a plugin.

The platform block in pubspec.yaml is what makes the plugin federated:

flutter:
  plugin:
    platforms:
      android:
        package: com.example.flutter_lite_camera
        pluginClass: FlutterLiteCameraPlugin
      ios:
        pluginClass: FlutterLiteCameraPlugin
      linux:
        pluginClass: FlutterLiteCameraPlugin
      macos:
        pluginClass: FlutterLiteCameraPlugin
      web:
        pluginClass: FlutterLiteCameraWeb
        fileName: flutter_lite_camera_web.dart
      windows:
        pluginClass: FlutterLiteCameraPluginCApi

Desktop code comes from the C++ litecam project: Camera.h is a small cross-platform capture class with Open, SetResolution, CaptureFrame, Release, and a StartCaptureLoop(callback) that forwards every frame as RGBA. Windows implements it with Media Foundation, Linux with V4L2. The two mobile platforms and the browser are written from scratch.

Designing a Camera API That Maps to Every Platform

The desktop-only version of this plugin needed six methods. Adding mobile and web support, plus runtime resolution control, brought the public surface to twelve:

Method Description
getDeviceList() Returns the available camera names. On Android the string includes the lens facing; on iOS/macOS the localized device name.
open(int index) Opens the camera at that index. Returns false for permission denial, a busy device, or an invalid index.
startPreview() Starts the native preview and returns a texture id.
buildPreview(textureId) Returns the preview widget: a Texture on native platforms, an HtmlElementView on the web.
stopPreview() Stops the preview stream and releases the texture.
captureFrame({int byteOrder = 2}) Captures one RGB888 frame plus its width and height.
setResolution(int width, int height) Requests a capture size; the platform negotiates the closest supported mode.
setResolutionPreset(ResolutionPreset preset) The same, using low, medium, high, veryHigh, ultraHigh, or max.
getWidth() The actual negotiated width, in display orientation.
getHeight() The actual negotiated height, in display orientation.
getRotation() Clockwise degrees (0/90/180/270) to apply to the preview and to result coordinates.
release() Releases the camera and every associated resource.

The facade stays deliberately thin — every call is one line of delegation to the platform interface:

class FlutterLiteCamera {
  Future<List<String>> getDeviceList() {
    return FlutterLiteCameraPlatform.instance.getDeviceList();
  }

  Future<bool> open(int index) {
    return FlutterLiteCameraPlatform.instance.open(index);
  }

  Future<int> startPreview() {
    return FlutterLiteCameraPlatform.instance.startPreview();
  }

  Widget buildPreview(int textureId) {
    return FlutterLiteCameraPlatform.instance.buildPreview(textureId);
  }

  Future<void> stopPreview() {
    return FlutterLiteCameraPlatform.instance.stopPreview();
  }

  Future<Map<String, dynamic>> captureFrame({int byteOrder = 2}) {
    return FlutterLiteCameraPlatform.instance.captureFrame(byteOrder: byteOrder);
  }

  Future<bool> setResolution(int width, int height) {
    return FlutterLiteCameraPlatform.instance.setResolution(width, height);
  }

  Future<bool> setResolutionPreset(ResolutionPreset preset) {
    return FlutterLiteCameraPlatform.instance.setResolutionPreset(preset);
  }

  Future<int> getWidth() {
    return FlutterLiteCameraPlatform.instance.getWidth();
  }

  Future<int> getHeight() {
    return FlutterLiteCameraPlatform.instance.getHeight();
  }

  Future<int> getRotation() {
    return FlutterLiteCameraPlatform.instance.getRotation();
  }

  Future<void> release() {
    return FlutterLiteCameraPlatform.instance.release();
  }
}

Resolution targets are an enum, so callers do not have to remember pixel pairs:

enum ResolutionPreset {
  low(320, 240),
  medium(640, 480),
  high(1280, 720),
  veryHigh(1920, 1080),
  ultraHigh(3840, 2160),
  max(7680, 4320);

  const ResolutionPreset(this.width, this.height);

  final int width;
  final int height;
}

Three design decisions are worth calling out because they leak into every backend:

  • buildPreview() instead of a raw Texture. Native platforms return Texture(textureId: id), but the browser has no texture registry — it hosts the <video> element in a platform view. Returning a widget keeps that difference inside the plugin.
  • getRotation() instead of rotating pixels. Rotating a 1080p RGBA buffer every frame on the CPU is wasteful. The plugin reports the angle and Dart rotates the widget, which the compositor does for free.
  • byteOrder only on Android. Android’s YUV_420_888 layout is not consistent across HALs, so the conversion exposes the channel arrangement: 0=RGB, 1=RBG, 2=GRB, 3=GBR, 4=BRG, 5=BGR. The default, 2, is the value verified against on-device reference colors.

Rendering the Preview Without Crossing the Platform Channel

All five native backends implement the same contract: the capture thread writes into a buffer the engine samples, and a “frame available” notification tells Flutter to re-composite. The mechanism differs per platform, so each section below shows only the part that is unique.

Windows

  1. Copy Camera.h and CameraWindows.cpp from the litecam project into the windows folder.

  2. Add the source files and link the Media Foundation libraries in CMakeLists.txt:

     add_library(${PLUGIN_NAME} SHARED
       "include/flutter_lite_camera/flutter_lite_camera_plugin_c_api.h"
       "flutter_lite_camera_plugin_c_api.cpp"
       "CameraWindows.cpp"
       "texture_handler.cpp"
       "texture_handler.h"
       ${PLUGIN_SOURCES}
     )
    
     target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin)
     target_link_libraries(${PLUGIN_NAME} PRIVATE ole32 uuid mfplat mf mfreadwrite mfuuid)
    
  3. Wrap a flutter::PixelBufferTexture in a TextureHandler. The capture thread pushes RGBA with UpdateBuffer(), and the raster thread pulls through the pixel-buffer callback. The buffer mutex is handed to Flutter through the release callback, so the source buffer stays valid until the rasterizer is done with it:

     class TextureHandler
     {
     public:
         explicit TextureHandler(flutter::TextureRegistrar *texture_registrar)
             : texture_registrar_(texture_registrar) {}
         ~TextureHandler();
    
         int64_t RegisterTexture();
         void UnregisterTexture();
         bool TextureRegistered() const { return texture_id_ >= 0; }
         int64_t texture_id() const { return texture_id_; }
    
         // Called from the camera capture thread with RGBA8888 data.
         void UpdateBuffer(const unsigned char *rgbaData, int width, int height);
    
     private:
         flutter::TextureRegistrar *texture_registrar_;
         int64_t texture_id_ = -1;
         std::unique_ptr<flutter::TextureVariant> texture_;
         std::unique_ptr<FlutterDesktopPixelBuffer> flutter_desktop_pixel_buffer_;
    
         std::mutex buffer_mutex_;
         std::vector<unsigned char> source_buffer_; // RGBA8888
         uint32_t frame_width_ = 0;
         uint32_t frame_height_ = 0;
     };
    
  4. Handle startPreview by registering the texture and starting the capture loop. Desktop backends report 0 for rotation, because the camera is already exposed in window orientation:

     else if (method_call.method_name().compare("startPreview") == 0)
     {
       if (camera->IsStreaming() && texture_handler_ && texture_handler_->TextureRegistered())
       {
         result->Success(flutter::EncodableValue(texture_handler_->texture_id()));
         return;
       }
    
       texture_handler_ = std::make_unique<TextureHandler>(texture_registrar_);
       int64_t texture_id = texture_handler_->RegisterTexture();
       if (texture_id < 0)
       {
         texture_handler_ = nullptr;
         result->Error("TextureError", "Failed to register texture");
         return;
       }
    
       TextureHandler *handler = texture_handler_.get();
       bool started = camera->StartCaptureLoop(
           [handler](const unsigned char *rgbaData, int width, int height)
           {
             handler->UpdateBuffer(rgbaData, width, height);
           });
    
       if (!started)
       {
         texture_handler_ = nullptr;
         result->Error("CameraError", "Failed to start preview. Is the camera open?");
         return;
       }
    
       result->Success(flutter::EncodableValue(texture_id));
     }
     else if (method_call.method_name().compare("getWidth") == 0)
     {
       int width = camera->frameWidth;
       result->Success(flutter::EncodableValue(width));
     }
     else if (method_call.method_name().compare("getRotation") == 0)
     {
       // Desktop cameras are exposed in landscape window orientation; no
       // rotation is needed.
       result->Success(flutter::EncodableValue(0));
     }
    

Linux

  1. Copy Camera.h and CameraLinux.cpp into the linux folder and add a camera_texture.cc / camera_texture.h pair for the pixel-buffer texture.

  2. Register the sources and link GTK:

     list(APPEND PLUGIN_SOURCES
       "flutter_lite_camera_plugin.cc"
       "camera_texture.cc"
       "camera_texture.h"
     )
    
     add_library(${PLUGIN_NAME} SHARED
       "CameraLinux.cpp"
       ${PLUGIN_SOURCES}
     )
    
     target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
     target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
    
  3. The texture is a FlPixelBufferTexture with a double buffer, so the capture thread can overwrite the latest frame while Flutter is still reading the previous one:

     struct CameraTextureBuffer
     {
         std::mutex mutex;
         std::vector<uint8_t> latest;  // Written by the capture thread, RGBA8888
         std::vector<uint8_t> present; // Handed to Flutter in copy_pixels
         uint32_t width = 0;
         uint32_t height = 0;
         bool has_frame = false;
     };
    
     // Thread-safe; called from the camera capture thread.
     void camera_texture_update_frame(CameraTexture *texture, const uint8_t *rgba,
                                      uint32_t width, uint32_t height);
    
  4. fl_texture_registrar_mark_texture_frame_available must run on the main thread, but frames arrive on the capture thread, so the callback hops through g_idle_add. The atomic flag coalesces bursts — at 60 fps there is no point queuing sixty idle callbacks per second:

     static gboolean mark_frame_available_cb(gpointer user_data)
     {
       FlutterLiteCameraPlugin *self = FLUTTER_LITE_CAMERA_PLUGIN(user_data);
       if (self->texture_registrar != nullptr && self->texture != nullptr)
       {
         fl_texture_registrar_mark_texture_frame_available(self->texture_registrar, FL_TEXTURE(self->texture));
       }
       g_atomic_int_set(&self->mark_pending, 0);
       return G_SOURCE_REMOVE;
     }
    
     bool started = self->camera->StartCaptureLoop(
         [plugin](const unsigned char *rgbaData, int width, int height)
         {
           camera_texture_update_frame(plugin->texture, rgbaData,
                                       static_cast<uint32_t>(width),
                                       static_cast<uint32_t>(height));
           if (g_atomic_int_compare_and_exchange(&plugin->mark_pending, 0, 1))
           {
             g_idle_add(mark_frame_available_cb, plugin);
           }
         });
    
  5. Stop in the right order. Join the capture thread first, then unregister the texture, otherwise a frame callback can fire against a disposed GObject:

     static void stop_preview(FlutterLiteCameraPlugin *self)
     {
       // Join the capture thread first so no frame callback can run while the
       // texture is being torn down.
       self->camera->StopCaptureLoop();
    
       if (self->texture_registrar != nullptr && self->texture != nullptr)
       {
         fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture));
         self->texture_id = -1;
       }
       g_clear_object(&self->texture);
     }
    

macOS and iOS

Both Apple platforms run the same AVFoundation pipeline. macOS is the simpler of the two, so the excerpts below come from it, and the iOS differences follow at the end of this section.

  1. Configure the session, pick the closest format to the requested resolution, and request BGRA output:

     // Inside open(cameraIndex:), which runs in a do/catch block.
     let input = try AVCaptureDeviceInput(device: self.captureDevice!)
     self.captureSession = AVCaptureSession()
     self.captureSession?.beginConfiguration()
    
     if self.captureSession?.canAddInput(input) == true {
         self.captureSession?.addInput(input)
     } else {
         print("Cannot add input to session.")
         return false
     }
    
     // Pick the format closest to the requested resolution
     if let format = self.captureDevice?.formats.min(by: {
         let d0 = CMVideoFormatDescriptionGetDimensions($0.formatDescription)
         let d1 = CMVideoFormatDescriptionGetDimensions($1.formatDescription)
         return abs(Int(d0.width) - self.frameWidth) + abs(Int(d0.height) - self.frameHeight)
             < abs(Int(d1.width) - self.frameWidth) + abs(Int(d1.height) - self.frameHeight)
     }) {
         try self.captureDevice?.lockForConfiguration()
         self.captureDevice?.activeFormat = format
         self.captureDevice?.unlockForConfiguration()
     }
    
     self.videoOutput = AVCaptureVideoDataOutput()
     self.videoOutput?.videoSettings = [
         kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
     ]
     self.videoOutput?.alwaysDiscardsLateVideoFrames = true
     self.captureSession?.addOutput(self.videoOutput!)
     self.videoOutput?.setSampleBufferDelegate(
         self, queue: DispatchQueue.global(qos: .userInteractive))
     self.captureSession?.commitConfiguration()
     self.captureSession?.startRunning()
    
  2. The texture is a FlutterTexture that retains the latest CVPixelBuffer. Because the buffer is already BGRA — a format Flutter samples natively — the preview path performs no conversion at all:

     class CameraTexture: NSObject, FlutterTexture {
       private var pixelBuffer: CVPixelBuffer?
       private let lock = NSLock()
    
       /// Called on the Flutter raster thread. The returned buffer must be
       /// retained; Flutter releases it when done.
       func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
         lock.lock()
         defer { lock.unlock() }
         guard let buffer = pixelBuffer else { return nil }
         return Unmanaged.passRetained(buffer)
       }
    
       /// Called on the camera capture queue.
       func update(_ buffer: CVPixelBuffer) {
         lock.lock()
         pixelBuffer = buffer
         lock.unlock()
       }
     }
    
  3. Both CameraManager implementations expose a rotation angle, but only the iOS plugin forwards it — macOS reports 0, since external displays and built-in laptop cameras do not rotate. The sensor is physically landscape, so a phone held in portrait needs 90 degrees:

     func rotationDegrees() -> Int {
         let sensor = sensorOrientationDegrees()
         let device = deviceOrientationDegrees()
         return (sensor - device + 360) % 360
     }
    
  4. Add the frameworks to the podspec. AVFoundation captures, CoreMedia carries the sample buffers, CoreVideo owns the pixel buffers:

     s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo']
    
  5. On iOS, three things differ from macOS. The plugin uses registrar.messenger() and registrar.textures() as methods rather than properties. open() requests permission first and always answers on the main queue. And startRunning() blocks until the first frame arrives, so it must not run on the main thread:

     func open(cameraIndex: Int, completion: @escaping (Bool) -> Void) {
         switch AVCaptureDevice.authorizationStatus(for: .video) {
         case .authorized:
             doOpen(cameraIndex: cameraIndex, completion: completion)
         case .notDetermined:
             AVCaptureDevice.requestAccess(for: .video) { granted in
                 DispatchQueue.main.async {
                     if granted {
                         self.doOpen(cameraIndex: cameraIndex, completion: completion)
                     } else {
                         completion(false)
                     }
                 }
             }
         default:
             DispatchQueue.main.async { completion(false) }
         }
     }
    
     // startRunning blocks until the first frame arrives, so it must never run
     // on the main thread on iOS.
     DispatchQueue.global(qos: .userInitiated).async {
         session.startRunning()
         DispatchQueue.main.async { completion(true) }
     }
    

Android

Camera2 needs two outputs in one session: the engine’s SurfaceTexture for the preview, and a YUV_420_888 ImageReader that keeps the latest frame for captureFrame(). Both live in the same repeating request, so pulling a frame never interrupts the preview.

  1. Create the SurfaceTexture from Flutter’s texture registry and attach it to the camera that open() already started:

     "startPreview" -> {
         synchronized(this) {
             val registry = textureRegistry
             if (registry == null) {
                 result.error("TEXTURE_ERROR", "Texture registry unavailable", null)
                 return
             }
             val entry = registry.createSurfaceTexture()
             if (!manager.startPreview(entry.surfaceTexture())) {
                 entry.release()
                 result.error(
                     "CAMERA_ERROR",
                     "Failed to start preview. Is the camera open?",
                     null,
                 )
                 return
             }
             surfaceTextureEntry = entry
             result.success(entry.id())
         }
     }
    
  2. Build the session around the available surfaces. A Surface wrapper is kept per texture and released when the texture changes, so repeated resolution switches do not leak one Surface per call:

     private fun buildSession(device: CameraDevice, onConfigured: (Boolean) -> Unit) {
         val texture = previewSurfaceTexture
         if (texture !== bgPreviewTexture) {
             try { bgPreviewSurface?.release() } catch (ignored: Exception) {}
             bgPreviewSurface = texture?.let { Surface(it) }
             bgPreviewTexture = texture
         }
    
         val outputs = mutableListOf<Surface>()
         bgPreviewSurface?.let { outputs.add(it) }
         imageReader?.surface?.let { outputs.add(it) }
    
         device.createCaptureSession(outputs, object : CameraCaptureSession.StateCallback() {
             override fun onConfigured(session: CameraCaptureSession) {
                 captureSession = session
                 val request = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
                 for (surface in outputs) request.addTarget(surface)
                 request.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO)
                 session.setRepeatingRequest(request.build(), null, backgroundHandler)
                 onConfigured(true)
             }
    
             override fun onConfigureFailed(session: CameraCaptureSession) {
                 onConfigured(false)
             }
         }, backgroundHandler)
     }
    
  3. Convert the cached YUV frame with BT.601 full-range integer math, honoring row and pixel strides so planar and semi-planar layouts both work. The rotation is applied while writing, which is why the returned frame matches the on-screen preview:

     // The HAL's plane order is not reliable, so the chroma roles are
     // exchanged and the pixels are written in GRB order — a combination
     // verified against on-device reference colors.
     val uData = v
     val vData = u
     val rotation = previewRotationDegrees()
     val outW = if (rotation % 180 == 90) h else w
     val outH = if (rotation % 180 == 90) w else h
     val rgb = ByteArray(w * h * 3)
    
     for (row in 0 until h) {
         val chromaRow = row / 2
         val uRow = chromaRow * uRowStride
         val vRow = chromaRow * vRowStride
         var yCol = row * yRs
         for (col in 0 until w) {
             val chromaCol = col / 2
             val cn = minOf(uRow + chromaCol * uPixelStride, uData.size - 1)
             val cb = minOf(vRow + chromaCol * vPixelStride, vData.size - 1)
             val yv = y[yCol++].toInt() and 0xFF
             val cv = (vData[cb].toInt() and 0xFF) - 128
             val cu = (uData[cn].toInt() and 0xFF) - 128
             // BT.601 full-range YUV -> RGB, fixed point (<<16 scale):
             // R = Y + 1.402*V, G = Y - 0.344*U - 0.714*V, B = Y + 1.772*U.
             var r = yv + ((cv * 91881) shr 16)
             var g = yv - ((cu * 22554 + cv * 46802) shr 16)
             var b = yv + ((cu * 116130) shr 16)
             if (r < 0) r = 0 else if (r > 255) r = 255
             if (g < 0) g = 0 else if (g > 255) g = 255
             if (b < 0) b = 0 else if (b > 255) b = 255
    
             // Write to the rotated destination position so the frame matches
             // the on-screen preview orientation.
             val dst = when (rotation) {
                 90 -> (col * outW + (h - 1 - row)) * 3
                 180 -> ((h - 1 - row) * outW + (w - 1 - col)) * 3
                 270 -> ((w - 1 - col) * outW + row) * 3
                 else -> (row * outW + col) * 3
             }
             rgb[dst] = g.toByte()
             rgb[dst + 1] = r.toByte()
             rgb[dst + 2] = b.toByte()
         }
     }
    
  4. Declare the permission in the host app. The plugin requests it at runtime from open(), and a denial is reported as a plain false — the same contract as every other platform:

     <uses-permission android:name="android.permission.CAMERA"/>
    

Web

The browser has neither a texture registry nor a native capture API, so the web implementation swaps both halves: the preview is an HtmlElementView hosting the <video> element, and captureFrame() draws that element into an offscreen canvas.

final deviceId = _videoDevices[index].deviceId;
final constraints = dom.MediaStreamConstraints(
  video: dom.MediaTrackConstraints(deviceId: deviceId.toJS),
  audio: false.toJS,
);

_stream = await dom.window.navigator.mediaDevices.getUserMedia(constraints).toDart;

_videoElement = dom.HTMLVideoElement()
  ..autoplay = true
  ..muted = true
  ..setAttribute('playsinline', '')
  ..srcObject = _stream;

startPreview() registers a view factory and returns an id that buildPreview() maps back to the view type. Because autoplay can be rejected before a user gesture, play() is retried when the preview starts:

// Inside startPreview(): wrap the video element so the platform view fills
// its slot, and remember the view type for buildPreview().
final viewType = 'flutter_lite_camera_$id';
_viewTypes[id] = viewType;

final wrapper = dom.HTMLDivElement()
  ..style.width = '100%'
  ..style.height = '100%'
  ..style.overflow = 'hidden'
  ..append(element);
ui_web.platformViewRegistry.registerViewFactory(viewType, (_) => wrapper);
return id;

Capturing a frame is a canvas read followed by dropping the alpha channel:

final canvas = dom.HTMLCanvasElement()
  ..width = width
  ..height = height;
final context = canvas.context2D;
context.drawImage(element, 0, 0, width.toDouble(), height.toDouble());

final rgba = context.getImageData(0, 0, width, height).data.toDart;

final rgb = Uint8List(width * height * 3);
for (var src = 0, dst = 0; dst < rgb.length; src += 4, dst += 3) {
  rgb[dst] = rgba[src];
  rgb[dst + 1] = rgba[src + 1];
  rgb[dst + 2] = rgba[src + 2];
}

Resolution switching maps to applyConstraints, which the browser treats as an ideal — the same closest-match negotiation the native backends implement:

await tracks.first.applyConstraints(
    dom.MediaTrackConstraints(width: width.toJS, height: height.toJS)).toDart;

Step 1: Open the Camera and Read the Negotiated Size

Never hard-code 640x480 in the app. open() negotiates a starting mode, getWidth()/getHeight() report what the device actually gave you, and every captureFrame() repeats the size with the pixels:

final FlutterLiteCamera _camera = FlutterLiteCamera();
int _textureId = -1;
int _width = 0;
int _height = 0;
int _rotation = 0;
bool _isCameraOpened = false;

Future<void> _startCamera() async {
  try {
    List<String> devices = await _camera.getDeviceList();
    if (devices.isEmpty) {
      debugPrint('No camera devices found.');
      return;
    }
    if (!await _camera.open(0)) {
      // false = permission denied or the device is busy.
      debugPrint('Failed to open the camera.');
      return;
    }

    int textureId = await _camera.startPreview();
    int rotation = await _camera.getRotation();
    int width = await _camera.getWidth();
    int height = await _camera.getHeight();

    setState(() {
      _isCameraOpened = true;
      _textureId = textureId;
      _rotation = rotation;
      if (width > 0 && height > 0) {
        _width = width;
        _height = height;
      }
    });
  } catch (e) {
    debugPrint('Error initializing camera: $e');
  }
}

Step 2: Keep the Preview Upright and Correctly Cropped

Two independent problems meet in the preview widget. The texture may need rotating, and the frame aspect ratio rarely matches the window. RotatedBox handles the first at composite time, and the LayoutBuilder handles the second by letterboxing instead of stretching:

@override
Widget build(BuildContext context) {
  return Scaffold(
    body: Stack(
      children: [
        if (_textureId >= 0)
          LayoutBuilder(
            builder: (context, constraints) {
              final frameWidth = _width > 0 ? _width : 4;
              final frameHeight = _height > 0 ? _height : 3;
              final rotated = _rotation % 180 == 90;
              // A 90/270 degree rotation turns the displayed frame portrait,
              // so the aspect ratio flips.
              final imageAspectRatio = rotated
                  ? frameHeight / frameWidth
                  : frameWidth / frameHeight;
              final screenAspectRatio =
                  constraints.maxWidth / constraints.maxHeight;

              double drawWidth, drawHeight;
              if (imageAspectRatio > screenAspectRatio) {
                drawWidth = constraints.maxWidth;
                drawHeight = constraints.maxWidth / imageAspectRatio;
              } else {
                drawHeight = constraints.maxHeight;
                drawWidth = constraints.maxHeight * imageAspectRatio;
              }

              return Center(
                child: SizedBox(
                  width: drawWidth,
                  height: drawHeight,
                  child: Stack(
                    children: [
                      // Rotate only the widget — never the native pixels.
                      RotatedBox(
                        quarterTurns: (_rotation ~/ 90) % 4,
                        child: _camera.buildPreview(_textureId),
                      ),
                      // Overlay goes here (Step 5).
                    ],
                  ),
                ),
              );
            },
          )
        else
          const Center(child: Text('Camera not initialized')),
      ],
    ),
  );
}

Step 3: Switch Resolution at Runtime

Changing the resolution mid-stream rebuilds the capture session on the native side, so the sequence matters: request the size, then read the size back, then update your aspect ratio state. The device may quietly choose something else:

Future<void> _applyResolution(ResolutionPreset preset) async {
  setState(() => _preset = preset);
  bool ok = await _camera.setResolutionPreset(preset);
  if (!ok) {
    debugPrint('setResolutionPreset($preset) failed.');
    return;
  }
  int width = await _camera.getWidth();
  int height = await _camera.getHeight();
  if (width > 0 && height > 0) {
    setState(() {
      _width = width;
      _height = height;
    });
  }
}

A preset picker in the UI is then a dropdown over ResolutionPreset.values. On an older laptop, ultraHigh and max will silently land on 1280x720 or 1920x1080 — which is exactly why getWidth() exists.

Step 4: Decode Barcodes from Captured Frames

Add the barcode SDK and initialize it once:

flutter pub add flutter_barcode_sdk
FlutterBarcodeSdk? _barcodeReader;

Future<void> initBarcodeSDK() async {
  _barcodeReader = FlutterBarcodeSdk();
  await _barcodeReader!.setLicense(licenseKey);
  await _barcodeReader!.init();
}

The decode loop pulls a frame, decodes it, and schedules itself again. Because captureFrame() reads a cache instead of pausing the stream, the preview keeps running at full frame rate while this loop works:

Future<void> _decodeFrames() async {
  if (!_isCameraOpened || !_shouldDecode) return;

  if (!isDecoding && _barcodeReader != null) {
    isDecoding = true;
    try {
      Map<String, dynamic> frame = await _camera.captureFrame();
      if (frame.containsKey('data')) {
        _width = frame['width'];
        _height = frame['height'];
        Uint8List rgbBuffer = frame['data'];

        final ret = await _barcodeReader!.decodeImageBuffer(
          rgbBuffer,
          _width,
          _height,
          _width * 3,
          ImagePixelFormat.IPF_RGB_888.index,
          ImageRotation.rotation0.value,
        );

        setState(() => results = ret);
      }
    } catch (e) {
      // No frame available yet.
    }
    isDecoding = false;
  }

  if (_shouldDecode) {
    Future.delayed(const Duration(milliseconds: 30), _decodeFrames);
  }
}

Note the isDecoding guard. A decode call can outlast the 30 ms interval, and without the flag the loop would queue work faster than it can complete.

Step 5: Draw Results Over the Rotated Preview

Barcode coordinates arrive in frame space. When the preview is rotated, those coordinates must be rotated with it, and the scale factors differ per axis after a 90-degree turn:

class ResultPainter extends CustomPainter {
  final List<BarcodeResult> results;
  final int srcWidth;
  final int srcHeight;
  final double drawWidth;
  final double drawHeight;
  final int rotation;

  ResultPainter(
    this.results, {
    required this.srcWidth,
    required this.srcHeight,
    required this.drawWidth,
    required this.drawHeight,
    required this.rotation,
  });

  Offset _transform(double x, double y) {
    final q = (rotation ~/ 90) % 4;
    switch (q) {
      case 1: // 90 degrees clockwise
        final nx = srcHeight - y;
        final ny = x;
        return Offset(nx * drawWidth / srcHeight, ny * drawHeight / srcWidth);
      case 2: // 180 degrees
        final nx = srcWidth - x;
        final ny = srcHeight - y;
        return Offset(nx * drawWidth / srcWidth, ny * drawHeight / srcHeight);
      case 3: // 270 degrees clockwise
        final nx = y;
        final ny = srcWidth - x;
        return Offset(nx * drawWidth / srcHeight, ny * drawHeight / srcWidth);
      default:
        return Offset(x * drawWidth / srcWidth, y * drawHeight / srcHeight);
    }
  }

  @override
  void paint(Canvas canvas, Size size) {
    if (results.isEmpty) return;
    final boxPaint = Paint()
      ..color = Colors.blue
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;

    for (var result in results) {
      final a = _transform(result.x1.toDouble(), result.y1.toDouble());
      final b = _transform(result.x2.toDouble(), result.y2.toDouble());
      final c = _transform(result.x3.toDouble(), result.y3.toDouble());
      final d = _transform(result.x4.toDouble(), result.y4.toDouble());

      canvas.drawPath(
        Path()
          ..moveTo(a.dx, a.dy)
          ..lineTo(b.dx, b.dy)
          ..lineTo(c.dx, c.dy)
          ..lineTo(d.dx, d.dy)
          ..close(),
        boxPaint,
      );

      final textPainter = TextPainter(
        text: TextSpan(
          text: result.text,
          style: const TextStyle(color: Colors.red, fontSize: 16),
        ),
        textDirection: TextDirection.ltr,
      )..layout();
      textPainter.paint(canvas, a);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

Flutter multi-barcode scanner on macOS

Common Issues & Edge Cases

  • Green or purple tint on macOS. Virtual and third-party cameras sometimes ignore the requested BGRA video setting. Since 0.2.2 the Apple backends read CVPixelBufferGetPixelFormatType and unpack 32BGRA, 32RGBA, or 32ARGB accordingly instead of assuming BGRA.
  • Blue objects render pink on Android. Two bugs stack here: the chroma planes must be read with their roles exchanged, and the output must be written in GRB order. That is why the default byteOrder is 2. If one device still looks wrong, try another order before touching the math.
  • Overlay boxes drift after a resolution change. The overlay divides by _width/_height; both must be refreshed from getWidth()/getHeight() after every setResolution() call, because the device may have chosen a different mode.
  • iOS preview appears sideways. getRotation() returns 90 on a phone in portrait. Apply it with RotatedBox and to the result coordinates — do not rotate the pixel buffer.
  • Android overlay is offset by 90 degrees. The opposite problem: Android frames arrive already rotated to match the preview, so getRotation() is typically 0 and applying it again would double-rotate.
  • open() returns false. On macOS the entitlements key is missing; on iOS the user denied camera access; on Android the manifest declaration is missing or another app holds the device; on an emulator the AVD has no camera configured.
  • Blank preview on the web. getUserMedia requires HTTPS or localhost, and autoplay needs a user gesture. startPreview() retries play() for exactly this reason.
  • setResolution() returns false. No camera is open. Open the device first, then set the size.

Common Developer Questions

Which platforms does the official Flutter camera plugin support?

The official camera plugin ships Android, iOS, and web implementations. Windows, Linux, and macOS require a community package or a custom plugin, which is why flutter_lite_camera implements all six targets with a single Dart API.

Why does captureFrame() exist when the preview is already a texture?

The texture lives on the GPU and is consumed by Flutter’s compositor; an image processing SDK needs CPU-addressable bytes. captureFrame() copies one frame into an RGB888 buffer on demand, while the preview keeps running from the native cache.

How do I get correct colors from Android frames?

Call captureFrame(byteOrder: n) where n is 0=RGB, 1=RBG, 2=GRB, 3=GBR, 4=BRG, 5=BGR. The default, 2, is verified against on-device reference colors and covers most devices; a few HALs lay out their chroma planes differently and need another value.

Does setResolution() fail when the exact size is unsupported?

No. Every backend selects the closest supported mode and reports the result through getWidth()/getHeight() and each captureFrame() payload. It returns false only when no camera is open.

Can I scan barcodes without starting the preview?

Yes. With a preview running, captureFrame() returns the latest cached frame. Without one, the Windows and Linux backends perform a blocking on-demand read from the device, and the Apple and Android backends serve the buffer their capture session already keeps. The difference is latency, not capability.

Source Code

Get the complete sample project on GitHub: flutter_lite_camera. The package is also on pub.dev, and the hosted demo is the same example compiled to the web.