How to Build a Lightweight Flutter Camera Plugin for Windows, Linux, and macOS

While Flutter supports six plaformsWindows, Linux, macOS, Android, iOS and web — the official Flutter camera plugin is limited to just three: Android, iOS, and web. The development pace for the official plugin has been sluggish, with no clear roadmap for desktop support. As a result, creating a cross-platform barcode scanner application that encompasses all six platforms remains unfeasible. To solve this problem, we will design and implement a desktop Flutter camera plugin from scratch, utilizing our C++ litecam project.

Desktop Flutter Multi-Barcode Scanner Demo

Scaffolding a Flutter Plugin Project for Windows, Linux, and macOS

Run the following command to create a new Flutter plugin project named flutter_lite_camera. This project will include platform-specific folders and initial code for Windows, Linux, and macOS:

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

The supported programming languages for Windows and Linux are C++, while macOS uses Swift. The source code from the litecam project can be reused for Windows and Linux. For macOS, we need to adapt the Objective-C logic to Swift.

Defining the API for Desktop Camera Functionality

When using litecam, the camera feed is typically displayed in a system window. However, since Flutter has its own UI system, we need to render captured frames within a Flutter widget. To accomplish this, we define six essential functions — a pair of preview methods (startPreview() and stopPreview()) plus the four capture/control methods:

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();
  }

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

  Future<Map<String, dynamic>> captureFrame() {
    return FlutterLiteCameraPlatform.instance.captureFrame();
  }

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

Explanation:

  • getDeviceList(): Retrieves a list of available camera devices.
  • open(int index): Opens the camera device with the specified index.
  • startPreview(): Starts the native preview stream and returns a texture id that you can feed to a Flutter Texture widget to render the live video feed.
  • stopPreview(): Stops the preview stream and unregisters the texture.
  • captureFrame(): Captures a frame as an RGB888 image. While a preview is running, the frame comes from a native cache, so grabbing a frame for image processing does not disturb the video stream.
  • release(): Releases the camera device, freeing up resources.

Implementing the Native Interface for Desktop Platforms

The default target resolution is 640x480; each platform negotiates with the device and reports the actual frame width and height through the API. The plugin renders the live preview at the native layer using a Flutter texture, so no per-frame pixel data crosses the platform channel just to display the feed. When you need pixels for image processing (such as barcode decoding), captureFrame() returns a single RGB888 frame on demand without disturbing the preview stream.

Windows

  1. Copy the Camera.h and CameraWindows.cpp files from the litecam project to the windows folder.
  2. Update the CMakelists.txt file to include the CameraWindows.cpp file, the texture_handler files, and link the required libraries:

     ...
        
     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)
     ...
    

The TextureHandler class wraps a flutter::PixelBufferTexture. The camera capture thread pushes RGBA frames with UpdateBuffer(), while the Flutter raster thread pulls them through the pixel-buffer callback:

```cpp
class TextureHandler
{
public:
    explicit TextureHandler(flutter::TextureRegistrar *texture_registrar)
        : texture_registrar_(texture_registrar) {}

    int64_t RegisterTexture();
    void UnregisterTexture();

    // 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
};
```
  1. Implement the method channel logic in flutter_lite_camera_plugin.cpp:

     #include "flutter_lite_camera_plugin.h"
     #include <windows.h>
     #include <VersionHelpers.h>
        
     #include <flutter/method_channel.h>
     #include <flutter/plugin_registrar_windows.h>
     #include <flutter/standard_method_codec.h>
        
     #include <memory>
     #include <sstream>
     #include <codecvt>
        
     namespace flutter_lite_camera
     {
        
       ...
        
       FlutterLiteCameraPlugin::FlutterLiteCameraPlugin(flutter::TextureRegistrar *texture_registrar)
           : texture_registrar_(texture_registrar)
       {
         camera = new Camera();
       }
        
       FlutterLiteCameraPlugin::~FlutterLiteCameraPlugin()
       {
         StopPreview();
         delete camera;
       }
        
       void FlutterLiteCameraPlugin::StopPreview()
       {
         camera->StopCaptureLoop();
         if (texture_handler_)
         {
           texture_handler_->UnregisterTexture();
           texture_handler_ = nullptr;
         }
       }
        
       void FlutterLiteCameraPlugin::HandleMethodCall(
           const flutter::MethodCall<flutter::EncodableValue> &method_call,
           std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result)
       {
         if (method_call.method_name().compare("getDeviceList") == 0)
         {
           std::vector<CaptureDeviceInfo> devices = ListCaptureDevices();
           flutter::EncodableList deviceList;
           for (size_t i = 0; i < devices.size(); i++)
           {
             CaptureDeviceInfo &device = devices[i];
        
             std::wstring wstr(device.friendlyName);
             int size_needed = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), NULL, 0, NULL, NULL);
             std::string utf8Str(size_needed, 0);
             WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), &utf8Str[0], size_needed, NULL, NULL);
        
             deviceList.push_back(flutter::EncodableValue(utf8Str));
           }
        
           result->Success(flutter::EncodableValue(deviceList));
         }
         else if (method_call.method_name().compare("open") == 0)
         {
           const auto *arguments = std::get_if<flutter::EncodableList>(method_call.arguments());
        
           if (arguments && !arguments->empty())
           {
             int index = std::get<int>((*arguments)[0]);
             StopPreview();
             bool success = camera->Open(index);
             result->Success(flutter::EncodableValue(success));
           }
           else
           {
             result->Error("InvalidArguments", "Expected camera index");
           }
         }
         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("stopPreview") == 0)
         {
           StopPreview();
           result->Success();
         }
         else if (method_call.method_name().compare("captureFrame") == 0)
         {
           FrameData frame = camera->CaptureFrame();
           if (frame.rgbData)
           {
             flutter::EncodableMap frameMap;
             frameMap[flutter::EncodableValue("width")] = flutter::EncodableValue(frame.width);
             frameMap[flutter::EncodableValue("height")] = flutter::EncodableValue(frame.height);
             frameMap[flutter::EncodableValue("data")] = flutter::EncodableValue(std::vector<uint8_t>(frame.rgbData, frame.rgbData + frame.size));
             ReleaseFrame(frame);
             result->Success(flutter::EncodableValue(frameMap));
           }
           else
           {
             result->Error("CaptureFailed", "Failed to capture frame");
           }
         }
         else if (method_call.method_name().compare("release") == 0)
         {
           StopPreview();
           camera->Release();
           result->Success();
         }
         else
         {
           result->NotImplemented();
         }
       }
        
     } 
        
    

Linux

  1. Copy the Camera.h and CameraLinux.cpp files from the litecam project to the linux folder.
  2. Update the CMakelists.txt file to include the CameraLinux.cpp file and link the required libraries:

     ...
        
     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. Implement the method channel logic in flutter_lite_camera_plugin.cc:

     #include "include/Camera.h"
     #include "include/flutter_lite_camera/flutter_lite_camera_plugin.h"
        
     #include <flutter_linux/flutter_linux.h>
     #include <gtk/gtk.h>
     #include <sys/utsname.h>
        
     #include <cstring>
        
     #include "flutter_lite_camera_plugin_private.h"
        
     #define FLUTTER_LITE_CAMERA_PLUGIN(obj)                                     \
       (G_TYPE_CHECK_INSTANCE_CAST((obj), flutter_lite_camera_plugin_get_type(), \
                                   FlutterLiteCameraPlugin))
        
     struct _FlutterLiteCameraPlugin
     {
       GObject parent_instance;
       Camera *camera;
       FlTextureRegistrar *texture_registrar;
       CameraTexture *texture;
       int64_t texture_id;
       gint mark_pending;
     };
        
     G_DEFINE_TYPE(FlutterLiteCameraPlugin, flutter_lite_camera_plugin, g_object_get_type())
        
     // Runs on the main thread; scheduled from the capture thread via g_idle_add.
     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;
     }
        
     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);
     }
        
     static void flutter_lite_camera_plugin_handle_method_call(
         FlutterLiteCameraPlugin *self,
         FlMethodCall *method_call)
     {
       g_autoptr(FlMethodResponse) response = nullptr;
        
       const gchar *method = fl_method_call_get_name(method_call);
        
       if (strcmp(method, "getDeviceList") == 0)
       {
         std::vector<CaptureDeviceInfo> devices = ListCaptureDevices();
         FlValue *deviceList = fl_value_new_list();
         for (const auto &device : devices)
         {
           FlValue *deviceName = fl_value_new_string(device.friendlyName);
           fl_value_append_take(deviceList, deviceName);
         }
         response = FL_METHOD_RESPONSE(fl_method_success_response_new(deviceList));
       }
       else if (strcmp(method, "open") == 0)
       {
         FlValue *args = fl_method_call_get_args(method_call);
         FlValue *index = fl_value_get_list_value(args, 0);
        
         if (index)
         {
           int index_int = fl_value_get_int(index);
           stop_preview(self);
           bool success = self->camera->Open(index_int);
           response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(success)));
         }
         else
         {
           response = FL_METHOD_RESPONSE(fl_method_error_response_new("INVALID_ARGUMENTS", "Expected camera index", nullptr));
         }
       }
       else if (strcmp(method, "startPreview") == 0)
       {
         if (self->texture != nullptr && self->texture_id >= 0)
         {
           response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(self->texture_id)));
         }
         else
         {
           self->texture = camera_texture_new();
           if (!fl_texture_registrar_register_texture(self->texture_registrar, FL_TEXTURE(self->texture)))
           {
             g_clear_object(&self->texture);
             response = FL_METHOD_RESPONSE(fl_method_error_response_new("TEXTURE_ERROR", "Failed to register texture", nullptr));
           }
           else
           {
             self->texture_id = fl_texture_get_id(FL_TEXTURE(self->texture));
    
             FlutterLiteCameraPlugin *plugin = self;
             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));
                   // fl_texture_registrar_* must be called on the main thread.
                   if (g_atomic_int_compare_and_exchange(&plugin->mark_pending, 0, 1))
                   {
                     g_idle_add(mark_frame_available_cb, plugin);
                   }
                 });
    
             if (!started)
             {
               fl_texture_registrar_unregister_texture(self->texture_registrar, FL_TEXTURE(self->texture));
               g_clear_object(&self->texture);
               self->texture_id = -1;
               response = FL_METHOD_RESPONSE(fl_method_error_response_new("CAMERA_ERROR", "Failed to start preview. Is the camera open?", nullptr));
             }
             else
             {
               response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(self->texture_id)));
             }
           }
         }
       }
       else if (strcmp(method, "stopPreview") == 0)
       {
         stop_preview(self);
         response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
       }
       else if (strcmp(method, "captureFrame") == 0)
       {
         FrameData frame = self->camera->CaptureFrame();
         if (frame.rgbData == nullptr)
         {
           response = FL_METHOD_RESPONSE(fl_method_error_response_new("CAPTURE_FAILED", "No frame data available", nullptr));
         }
         else
         {
           FlValue *frameData = fl_value_new_map();
           fl_value_set_take(frameData, fl_value_new_string("width"), fl_value_new_int(frame.width));
           fl_value_set_take(frameData, fl_value_new_string("height"), fl_value_new_int(frame.height));
        
           FlValue *rgbData = fl_value_new_uint8_list(frame.rgbData, frame.size);
           fl_value_set_take(frameData, fl_value_new_string("data"), rgbData);
           ReleaseFrame(frame);
           response = FL_METHOD_RESPONSE(fl_method_success_response_new(frameData));
         }
       }
       else if (strcmp(method, "release") == 0)
       {
         stop_preview(self);
         self->camera->Release();
         response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
       }
       else
       {
         response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
       }
        
       if (response == nullptr)
       {
         response = FL_METHOD_RESPONSE(fl_method_error_response_new("INTERNAL_ERROR", "Unexpected error", nullptr));
       }
        
       fl_method_call_respond(method_call, response, nullptr);
     }
        
     static void flutter_lite_camera_plugin_dispose(GObject *object)
     {
       FlutterLiteCameraPlugin *self = FLUTTER_LITE_CAMERA_PLUGIN(object);
       stop_preview(self);
       delete self->camera;
       g_clear_object(&self->texture_registrar);
       G_OBJECT_CLASS(flutter_lite_camera_plugin_parent_class)->dispose(object);
     }
        
     static void flutter_lite_camera_plugin_init(FlutterLiteCameraPlugin *self)
     {
       self->camera = new Camera();
       self->texture_registrar = nullptr;
       self->texture = nullptr;
       self->texture_id = -1;
       self->mark_pending = 0;
     }
     ...
    

macOS

  1. Create a CameraManager.swift file to implement the camera functionality:

    ```swift import AVFoundation import FlutterMacOS import Foundation

    class CameraManager: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { private var captureSession: AVCaptureSession? private var videoOutput: AVCaptureVideoDataOutput? private var captureDevice: AVCaptureDevice? private var frameWidth: Int = 640 private var frameHeight: Int = 480

    /// Called on the capture queue for every incoming frame. The pixel buffer /// is BGRA and can be handed to a FlutterTexture directly. var onFrame: ((CVPixelBuffer) -> Void)?

    private let bufferLock = NSLock() private var _latestPixelBuffer: CVPixelBuffer?

    private var latestPixelBuffer: CVPixelBuffer? { get { bufferLock.lock() defer { bufferLock.unlock() } return _latestPixelBuffer } set { bufferLock.lock() _latestPixelBuffer = newValue bufferLock.unlock() } }

    struct FrameData { var width: Int var height: Int var rgbData: Data }

    override init() { super.init() }

     func listDevices() -> [String] {
         let devices = AVCaptureDevice.devices()
             .filter { $0.hasMediaType(.video) }
         return devices.map { $0.localizedName }
     }
    
     func open(cameraIndex: Int) -> Bool {
         guard cameraIndex < AVCaptureDevice.devices(for: .video).count else {
             print("Camera index out of range.")
             return false
         }
    
         let devices = AVCaptureDevice.devices(for: .video)
         self.captureDevice = devices[cameraIndex]
    
         do {
             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()
                 let d = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
                 print("Resolution set to \(d.width)x\(d.height)")
             } else {
                 print("\(self.frameWidth)x\(self.frameHeight) resolution not supported")
             }
    
             self.videoOutput = AVCaptureVideoDataOutput()
             self.videoOutput?.videoSettings = [
                 kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
             ]
             self.videoOutput?.alwaysDiscardsLateVideoFrames = true
    
             if self.captureSession?.canAddOutput(self.videoOutput!) == true {
                 self.captureSession?.addOutput(self.videoOutput!)
                 self.videoOutput?.setSampleBufferDelegate(
                     self, queue: DispatchQueue.global(qos: .userInteractive))
             } else {
                 print("Cannot add video output to session.")
                 return false
             }
    
             self.captureSession?.commitConfiguration()
             self.captureSession?.startRunning()
    
             return true
    
         } catch {
             print("Error initializing camera: \(error.localizedDescription)")
             return false
         }
     }
    

/// Converts the latest BGRA frame to RGB888 on demand. This runs only when /// a frame is explicitly requested (e.g. for barcode decoding) and never /// touches the preview path. func captureFrame() -> FrameData? { guard let pixelBuffer = latestPixelBuffer else { return nil }

        CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
        defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }

        let width = CVPixelBufferGetWidth(pixelBuffer)
        let height = CVPixelBufferGetHeight(pixelBuffer)
        let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)

        guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else {
            return nil
        }

        var rgbData = Data(count: width * height * 3)
        rgbData.withUnsafeMutableBytes { dstPointer in
            let dst = dstPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
            let src = baseAddress.assumingMemoryBound(to: UInt8.self)

            for y in 0..<height {
                let srcRow = src + y * bytesPerRow
                let dstRow = dst + y * width * 3
                for x in 0..<width {
                    // BGRA -> RGB
                    dstRow[x * 3] = srcRow[x * 4 + 2]
                    dstRow[x * 3 + 1] = srcRow[x * 4 + 1]
                    dstRow[x * 3 + 2] = srcRow[x * 4]
                }
            }
        }

        return FrameData(width: width, height: height, rgbData: rgbData)
    }

    func getWidth() -> Int {
        if let buffer = latestPixelBuffer {
            return CVPixelBufferGetWidth(buffer)
        }
        return self.frameWidth
    }

    func getHeight() -> Int {
        if let buffer = latestPixelBuffer {
            return CVPixelBufferGetHeight(buffer)
        }
        return self.frameHeight
    }

    func release() {
        self.onFrame = nil
        self.captureSession?.stopRunning()
        self.captureSession = nil
        self.videoOutput = nil
        self.captureDevice = nil
        self.latestPixelBuffer = nil
    }

    func captureOutput(
        _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            print("Failed to get pixel buffer.")
            return
        }

        // Retain the buffer for on-demand captureFrame() conversions, then
        // forward it to the preview texture without any pixel processing.
        self.latestPixelBuffer = pixelBuffer
        self.onFrame?(pixelBuffer)
    }
}

```
  1. Edit flutter_lite_camera.podspec to link the required frameworks:

     Pod::Spec.new do |s|
       ...
       s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo']
       ...
     end
    
    
  2. Implement the method channel logic in FlutterLiteCameraPlugin.swift:

     import Cocoa
     import FlutterMacOS
    
     /// FlutterTexture backed by the latest camera CVPixelBuffer. The buffer is
     /// BGRA, which Flutter supports natively, so preview rendering is zero-copy.
     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()
       }
     }
    
     public class FlutterLiteCameraPlugin: NSObject, FlutterPlugin {
       private let cameraManager = CameraManager()
       private var textureRegistry: FlutterTextureRegistry?
       private var cameraTexture: CameraTexture?
       private var textureId: Int64 = -1
    
       public static func register(with registrar: FlutterPluginRegistrar) {
         let channel = FlutterMethodChannel(
           name: "flutter_lite_camera", binaryMessenger: registrar.messenger)
         let instance = FlutterLiteCameraPlugin()
         instance.textureRegistry = registrar.textures
         registrar.addMethodCallDelegate(instance, channel: channel)
       }
    
       private func stopPreview() {
         cameraManager.onFrame = nil
         if textureId >= 0 {
           textureRegistry?.unregisterTexture(textureId)
           textureId = -1
         }
         cameraTexture = nil
       }
    
       public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
         switch call.method {
         case "getDeviceList":
           result(cameraManager.listDevices())
         case "open":
           if let args = call.arguments as? [Int], let index = args.first {
             stopPreview()
             result(cameraManager.open(cameraIndex: index))
           } else {
             result(FlutterError(code: "INVALID_ARGUMENT", message: "Index required", details: nil))
           }
         case "startPreview":
           if textureId >= 0 {
             result(textureId)
             return
           }
           guard let registry = textureRegistry else {
             result(FlutterError(code: "TEXTURE_ERROR", message: "Texture registry unavailable", details: nil))
             return
           }
           let texture = CameraTexture()
           let id = registry.register(texture)
           self.cameraTexture = texture
           self.textureId = id
           cameraManager.onFrame = { [weak self, weak texture] pixelBuffer in
             guard let self = self, let texture = texture else { return }
             texture.update(pixelBuffer)
             self.textureRegistry?.textureFrameAvailable(id)
           }
           result(id)
         case "stopPreview":
           stopPreview()
           result(nil)
         case "captureFrame":
           if let frame = cameraManager.captureFrame() {
             result([
               "width": frame.width,
               "height": frame.height,
               "data": frame.rgbData,
             ])
           } else {
             result(FlutterError(code: "CAPTURE_FAILED", message: "No frame available", details: nil))
           }
         case "release":
           stopPreview()
           cameraManager.release()
           result(nil)
         default:
           result(FlutterMethodNotImplemented)
         }
       }
     }
    

Building a Flutter Application to Display Camera Feed

Because the plugin now renders the preview at the native layer, displaying the camera feed in Flutter is much simpler: you feed the texture id returned by startPreview() to a Texture widget, and the native layer draws every frame directly. No pixel data crosses the platform channel just to show the feed.

  1. Open the camera and start the preview, then store the returned texture id:

     final FlutterLiteCamera _camera = FlutterLiteCamera();
     int _textureId = -1;
     int _width = 640;
     int _height = 480;
     bool _isCameraOpened = false;
    
     Future<void> _startCamera() async {
       try {
         List<String> devices = await _camera.getDeviceList();
         if (devices.isNotEmpty) {
           bool opened = await _camera.open(0);
           if (opened) {
             // The native layer renders the video feed into this texture; no
             // frame data crosses into Dart for display purposes.
             int textureId = await _camera.startPreview();
             setState(() {
               _isCameraOpened = true;
               _textureId = textureId;
             });
           }
         }
       } catch (e) {
         // Handle the error.
       }
     }
    
     Future<void> _stopCamera() async {
       if (_isCameraOpened) {
         await _camera.stopPreview();
         await _camera.release();
         setState(() {
           _isCameraOpened = false;
           _textureId = -1;
         });
       }
     }
    
  2. Display the live feed with a Texture widget and keep the aspect ratio with a LayoutBuilder:

     @override
     Widget build(BuildContext context) {
       return Scaffold(
         body: Stack(
           children: [
             if (_textureId >= 0)
               LayoutBuilder(
                 builder: (context, constraints) {
                   final screenWidth = constraints.maxWidth;
                   final screenHeight = constraints.maxHeight;
                   final imageAspectRatio = _width / _height;
                   final screenAspectRatio = screenWidth / screenHeight;
    
                   double drawWidth, drawHeight;
                   if (imageAspectRatio > screenAspectRatio) {
                     drawWidth = screenWidth;
                     drawHeight = screenWidth / imageAspectRatio;
                   } else {
                     drawHeight = screenHeight;
                     drawWidth = screenHeight * imageAspectRatio;
                   }
    
                   return Center(
                     child: SizedBox(
                       width: drawWidth,
                       height: drawHeight,
                       child: Texture(textureId: _textureId),
                     ),
                   );
                 },
               )
             else
               const Center(child: Text('Camera not initialized')),
           ],
         ),
       );
     }
    
  3. On macOS, grant camera access by adding the following key to DebugProfile.entitlements (and Release.entitlements):

     <key>com.apple.security.device.camera</key>
     <true/>
    

    desktop Flutter camera plugin

Integrating Multi-Barcode Scanning into the Flutter Application

Now that the desktop Flutter camera application is complete, we can integrate an image processing SDK like Dynamsoft Barcode Reader to enable multi-barcode scanning.

  1. Add the flutter_barcode_sdk package to your project by running the following command:

     flutter pub add flutter_barcode_sdk
    
  2. Visit the Dynamsoft website to obtain a 30-day free trial license key. Initialize the barcode reader as follows:

     import 'package:flutter_barcode_sdk/flutter_barcode_sdk.dart';
     FlutterBarcodeSdk? _barcodeReader;
     Future<void> initBarcodeSDK() async {
         _barcodeReader = FlutterBarcodeSdk();
         await _barcodeReader!.setLicense(licenseKey);
         await _barcodeReader!.init();
       }
    
  3. Decode 1D/2D barcodes from the captured frame using the flutter_barcode_sdk library. Frames are pulled from the native cache with captureFrame(), so decoding does not disturb the preview stream:

     bool _shouldDecode = false;
     bool isDecoding = false;
     List<BarcodeResult>? results;
    
     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);
       }
     }
    
  4. Draw the detection results on top of the preview. Create a CustomPaint painter that renders each barcode’s bounding box and text:

     class ResultPainter extends CustomPainter {
       final List<BarcodeResult> results;
       final double scale;
    
       ResultPainter(this.results, this.scale);
    
       @override
       void paint(Canvas canvas, Size size) {
         if (results.isEmpty) return;
    
         final textPaint = Paint()
           ..color = Colors.blue
           ..style = PaintingStyle.stroke
           ..strokeWidth = 2;
    
         for (var result in results) {
           final path = Path()
             ..moveTo(result.x1.toDouble() * scale, result.y1.toDouble() * scale)
             ..lineTo(result.x2.toDouble() * scale, result.y2.toDouble() * scale)
             ..lineTo(result.x3.toDouble() * scale, result.y3.toDouble() * scale)
             ..lineTo(result.x4.toDouble() * scale, result.y4.toDouble() * scale)
             ..close();
    
           canvas.drawPath(path, textPaint);
    
           final textPainter = TextPainter(
             text: TextSpan(
               text: result.text,
               style: const TextStyle(
                 color: Colors.red,
                 fontSize: 16,
               ),
             ),
             textDirection: TextDirection.ltr,
           );
    
           textPainter.layout();
           textPainter.paint(
             canvas,
             Offset(result.x1.toDouble() * scale, result.y1.toDouble() * scale),
           );
         }
       }
    
       @override
       bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
     }
    

    Then stack the painter over the Texture widget so the bounding boxes and text are drawn on top of the live feed:

     Texture(textureId: _textureId),
     CustomPaint(
       painter: ResultPainter(results ?? [], drawWidth / _width),
       child: Container(),
     ),
    

    desktop Flutter Multi-Barcode Scanner

Source Code

Get the complete sample project source code on GitHub: flutter_lite_camera.