diff --git a/plugins/camera_desktop/.github/workflows/ci.yml b/plugins/camera_desktop/.github/workflows/ci.yml
new file mode 100644
index 0000000..9fdac3d
--- /dev/null
+++ b/plugins/camera_desktop/.github/workflows/ci.yml
@@ -0,0 +1,159 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ analyze:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - run: flutter pub get
+ - run: flutter analyze
+
+ test-dart:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - run: flutter pub get
+ - run: flutter test
+
+ build-linux:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - name: Install Linux dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ clang cmake ninja-build pkg-config \
+ libgtk-3-dev \
+ libgstreamer1.0-dev \
+ libgstreamer-plugins-base1.0-dev \
+ gstreamer1.0-plugins-good \
+ libmpv-dev
+ - run: cd example && flutter build linux
+
+ build-macos:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - run: cd example && flutter build macos
+
+ test-macos-native:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - run: cd example && flutter build macos --debug
+ - name: Run XCTests
+ run: |
+ cd example/macos
+ xcodebuild test \
+ -workspace Runner.xcworkspace \
+ -scheme Runner \
+ -configuration Debug \
+ -quiet
+
+ build-windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ - uses: ilammy/msvc-dev-cmd@v1
+
+ - name: Verify /utf-8 is set for MSVC in windows/CMakeLists.txt
+ shell: bash
+ run: |
+ grep -q '/utf-8' windows/CMakeLists.txt \
+ || { echo "::error::windows/CMakeLists.txt is missing /utf-8"; exit 1; }
+
+ - name: Verify ci/cp936_repro.cpp covers every non-ASCII char in windows/
+ shell: bash
+ run: python ci/check_unicode_inventory.py
+
+ - name: Verify ci/cp936_repro.cpp has no UTF-8 BOM
+ shell: bash
+ run: |
+ bom=$(head -c 3 ci/cp936_repro.cpp | od -An -tx1 | tr -d ' \n')
+ if [ "$bom" = "efbbbf" ]; then
+ echo "::error::ci/cp936_repro.cpp must not have a BOM (MSVC would auto-detect UTF-8 and bypass /source-charset:.936)"
+ exit 1
+ fi
+ echo "OK: no BOM"
+
+ - name: CP936 simulation without /utf-8 must fail with C4819/C2220
+ shell: cmd
+ run: |
+ cl /c /WX /source-charset:.936 /execution-charset:.936 /nologo ci\cp936_repro.cpp > cl.log 2>&1
+ set CL_EXIT=%errorlevel%
+ type cl.log
+ if %CL_EXIT% equ 0 (
+ echo ::error::Expected C4819/C2220 but compile succeeded, CP936 simulation is not triggering the bug
+ exit /b 1
+ )
+ findstr /c:"C4819" cl.log >nul
+ if errorlevel 1 (
+ echo ::error::cl.exe failed but did not emit C4819, test is not reproducing the real bug
+ exit /b 1
+ )
+ findstr /c:"C2220" cl.log >nul
+ if errorlevel 1 (
+ echo ::error::cl.exe failed but did not emit C2220, /WX promotion is not working as expected
+ exit /b 1
+ )
+ echo OK: CP936 simulation reproduced C4819/C2220 as expected
+ exit /b 0
+
+ # Note: MSVC refuses `/source-charset:.936` together with `/utf-8`
+ # (error D8016: options are incompatible). On a real Chinese Windows
+ # host, CP936 is NOT a flag; it's an implicit default from GetACP().
+ # `/utf-8` overrides that implicit default. We prove the fix in two
+ # independent invocations: the previous step shows CP936 is hostile
+ # to our bytes; this step shows `/utf-8` makes MSVC read them as UTF-8
+ # and compile cleanly with /WX. Together they imply that on a real
+ # CP936 host, adding `/utf-8` switches MSVC from CP936-mode (fail)
+ # to UTF-8-mode (pass).
+ - name: Compile with /utf-8 must succeed (proves /utf-8 resolves our chars)
+ shell: cmd
+ run: cl /c /WX /utf-8 /nologo ci\cp936_repro.cpp
+
+ - name: Build the example (generates the plugin vcxproj)
+ run: cd example && flutter build windows
+
+ - name: Verify /utf-8 is threaded into the generated plugin vcxproj
+ shell: bash
+ run: |
+ vcxproj=$(find example/build/windows -name 'camera_desktop_plugin.vcxproj' | head -n1)
+ if [ -z "$vcxproj" ]; then
+ echo "::error::camera_desktop_plugin.vcxproj not found under example/build/windows"
+ find example/build/windows -name '*.vcxproj' || true
+ exit 1
+ fi
+ echo "Inspecting: $vcxproj"
+ if ! grep -q '/utf-8' "$vcxproj"; then
+ echo "::error::/utf-8 missing from $vcxproj, CMake did not thread the flag through"
+ echo "--- vcxproj contents ---"
+ cat "$vcxproj"
+ exit 1
+ fi
+ echo "OK: /utf-8 present in $vcxproj"
diff --git a/plugins/camera_desktop/.gitignore b/plugins/camera_desktop/.gitignore
new file mode 100644
index 0000000..4c554e7
--- /dev/null
+++ b/plugins/camera_desktop/.gitignore
@@ -0,0 +1,36 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
+/pubspec.lock
+**/doc/api/
+.dart_tool/
+.flutter-plugins-dependencies
+/build/
+/coverage/
+
+# C++ test build artifacts
+linux/test/build/
diff --git a/plugins/camera_desktop/.metadata b/plugins/camera_desktop/.metadata
new file mode 100644
index 0000000..d2fa360
--- /dev/null
+++ b/plugins/camera_desktop/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
+ channel: "stable"
+
+project_type: plugin
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ - platform: linux
+ create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/plugins/camera_desktop/.pubignore b/plugins/camera_desktop/.pubignore
new file mode 100644
index 0000000..d986d3e
--- /dev/null
+++ b/plugins/camera_desktop/.pubignore
@@ -0,0 +1,15 @@
+# Build / tooling artifacts
+.dart_tool/
+build/
+.idea/
+*.iml
+
+# Dev/CI helper scripts, not needed by package consumers.
+ci/
+
+# Example Podfile (not needed in the published tarball).
+example/**/Podfile
+example/**/Podfile.lock
+
+# Internal working notes, not for the published package.
+NEW_SUPPORT_ROADMAP.md
diff --git a/plugins/camera_desktop/CHANGELOG.md b/plugins/camera_desktop/CHANGELOG.md
new file mode 100644
index 0000000..10fe06b
--- /dev/null
+++ b/plugins/camera_desktop/CHANGELOG.md
@@ -0,0 +1,146 @@
+## 1.2.1
+
+* Fix Linux preview `Internal data stream error` (`not-negotiated`) on cameras that do not expose MJPEG, such as NV12/YUYV-only USB webcams. The MJPEG fast-path added in 1.1.7 was selected whenever its pipeline merely *parsed*, but `gst_parse_launch` succeeds even when the camera cannot produce MJPEG, so the intended raw-capture fallback never ran and initialization failed at `PLAYING`. The plugin now probes the V4L2 device (both `MJPEG` and `JPEG` pixel formats) for the target resolution before choosing the MJPEG path, and otherwise uses the always-safe raw capture path. The MJPEG pipeline pins only the resolution and lets the camera's native frame rate float, with `videorate` adapting it to the requested fps, so requesting a frame rate the camera does not expose natively in MJPEG no longer breaks negotiation.
+
+## 1.2.0
+
+* Speed up Windows camera initialization by completing `initialize()` as soon as the preview starts instead of blocking until the first camera frame arrives. On a typical webcam this returns control to the app in roughly 250 ms rather than about 2 seconds. The preview fills in as frames arrive, and a watchdog reports a `cameraError` if no frames are received within 8 seconds.
+* Make Windows diagnostic logging opt-in and off by default so the plugin stays quiet in your app. Set the `CAMERA_DESKTOP_LOG` environment variable to any value other than `0` to capture a trace when reporting an issue. This also removes the logging overhead that slowed camera initialization, most noticeably with a debugger or IDE attached.
+
+## 1.1.8
+
+* Fix Windows use-after-free crashes on camera dispose: guard the preview texture against in-flight preview samples, and defer destroying the texture until Flutter's asynchronous `UnregisterTexture` completes (its raster-thread pixel-buffer callback could otherwise run after the texture was freed) (#4)
+
+## 1.1.7
+
+* Fix Windows crash after camera dispose by marshalling platform channel messages to the platform thread (#4)
+* Fix Linux preview `not-negotiated` error on common USB webcams (#5, thanks @jvnonce)
+* Fix Linux recording on systems without `x264enc` (#5, thanks @jvnonce)
+* Report max frame rate across all pixel formats during Linux device enumeration (#5, thanks @jvnonce)
+
+## 1.1.6
+
+* Fix Swift compiler warnings: remove unused variables in DeviceEnumerator, PhotoHandler, RecordHandler, and CameraSession
+
+## 1.1.5
+
+* Fix SPM integration by adding missing FlutterFramework dependency to iOS and macOS Package.swift
+
+## 1.1.4
+
+* Update documentation
+* Remove debug logging from image stream pause/resume and video recording completion
+
+## 1.1.3
+
+* Fix Windows build failure (C4819 / C2220) on hosts with a non-UTF-8 system code page (e.g. CP936 on Simplified Chinese Windows) by compiling the plugin with `/utf-8` under MSVC (#2)
+
+## 1.1.2
+
+* Fix macOS build failure on Xcode 26+ by removing unavailable `AVCaptureSessionInterruptionReasonKey` (re-introduced in 1.1.1)
+* Fix Windows build failure caused by implicit `wchar_t` to `char` conversion in debug logging
+
+## 1.1.1
+
+* Add comprehensive diagnostic logging across all platforms (Linux, macOS, Windows)
+* Log device enumeration, backend selection, pipeline construction, resolution selection, recording lifecycle, and error paths
+
+## 1.1.0
+
+* Add PipeWire camera portal support for Flatpak sandbox compatibility on Linux
+* Automatically detect Flatpak environment and use `pipewiresrc` instead of `v4l2src`
+* Request camera access via `org.freedesktop.portal.Camera` D-Bus interface
+* Enumerate PipeWire camera nodes via `GstDeviceMonitor`
+* Fall back to V4L2 if portal is unavailable or user denies permission
+* No new build dependencies: uses GIO (D-Bus) and GStreamer APIs already linked
+
+## 1.0.8
+
+* Fix macOS build failure on Xcode 26+ by removing unavailable `AVCaptureSessionInterruptionReasonKey` (iOS-only API)
+
+## 1.0.7
+
+* Fix camera initialization failure on Intel Macs by selecting session preset after device discovery using `device.supportsSessionPreset()` with automatic fallback (1080p → 720p → high → medium)
+* Make `canAddInput`/`canAddOutput` failures return a `FlutterError` instead of silently skipping, preventing blank-screen timeouts
+* Increase initialization timeout from 8s to 15s for slower USB cameras
+* Subscribe to `AVCaptureSessionRuntimeError`, `WasInterrupted`, and `InterruptionEnded` notifications and forward to Dart via `cameraError`
+* Fix MethodChannel image stream fallback sending hardcoded `bytesPerRow` instead of actual value from `CVPixelBuffer`
+* Add diagnostic logging at all critical points in macOS session setup
+
+## 1.0.6
+
+* Fix Xcode build warnings by declaring PrivacyInfo.xcprivacy as a resource bundle in iOS and macOS podspecs
+
+## 1.0.5
+
+* Fixes #1: conflict with camera_android and camera_avfoundation dependencies
+
+## 1.0.4
+
+* Fix macOS Swift Package Manager compatibility
+
+## 1.0.3
+
+* Fix hot restart FFI crash by replacing NativeCallable with polling
+
+## 1.0.2
+
+* Fix macOS use-after-free crash during engine teardown by making dispose synchronous/idempotent and guarding FFI callbacks
+
+## 1.0.1
+
+* Fix xcprivacy build warnings by declaring resource_bundles in iOS and macOS podspecs
+
+## 1.0.0
+
+First stable release of `camera_desktop`
+
+### Platform implementations
+
+* **macOS**, AVFoundation (`AVCaptureSession`, `AVAssetWriter`). Preview via `CVPixelBuffer` textures, H.264/AAC recording, native mirror support.
+* **Windows**, Media Foundation (`IMFCaptureEngine`) with Direct3D 11 texture rendering. H.264/AAC recording via `IMFSinkWriter`.
+* **Linux**, GStreamer + V4L2 (`v4l2src → videoconvert → appsink` pipeline). H.264/AAC recording with automatic encoder selection, native mirror via `videoflip`.
+
+### Features
+
+* Live camera preview with hardware-accelerated texture rendering on all platforms
+* Photo capture, video recording, and real-time image streaming
+* FFI-based zero-copy frame delivery (MethodChannel fallback for compatibility)
+* Configurable resolution presets, FPS (5-60), and video bitrate
+* Mirror/flip control (macOS and Linux)
+* Pause/resume preview
+* Runtime capability querying via `getPlatformCapabilities()`
+
+## 0.0.8
+
+* Migrate Windows implementation to IMFCaptureEngine
+
+## 0.0.7
+
+* Update example app to show settings panel
+
+## 0.0.5
+
+* Fix C linkage on Linux
+
+## 0.0.4
+
+* FFI-based image stream for reduced memory copies (3→2 per frame)
+* Fix macOS Swift/ObjC interop for FFI bridge
+* Fix image format reporting (Linux/Windows RGBA vs macOS BGRA)
+
+## 0.0.3
+
+* Performance improvements
+
+## 0.0.2
+
+* Add setMirror API and built-in camera sorting for DeviceEnumerator
+
+## 0.0.1
+
+* Linux camera support via GStreamer + V4L2.
+* macOS camera support via AVFoundation.
+* Windows camera support via Media Foundation.
+* Full `camera_platform_interface` compliance.
+* Photo capture, video recording, image streaming, and live preview.
diff --git a/plugins/camera_desktop/LICENSE b/plugins/camera_desktop/LICENSE
new file mode 100644
index 0000000..0b1e98b
--- /dev/null
+++ b/plugins/camera_desktop/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hugo Cornellier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/camera_desktop/README.md b/plugins/camera_desktop/README.md
new file mode 100644
index 0000000..5339321
--- /dev/null
+++ b/plugins/camera_desktop/README.md
@@ -0,0 +1,199 @@
+
camera_desktop
+
+
+
+
+
+
+
+
+
+
+
+A Flutter camera plugin for desktop platforms. Implements
+[`camera_platform_interface`](https://pub.dev/packages/camera_platform_interface)
+so it works seamlessly with the standard
+[`camera`](https://pub.dev/packages/camera) package and `CameraController`.
+
+## Platform Support
+
+| Platform | Backend | Status |
+|----------|---------|--------|
+| **Linux** | GStreamer + V4L2 | Included |
+| **macOS** | AVFoundation | Included |
+| **Windows** | Media Foundation | Included |
+
+## Installation
+
+Add `camera_desktop` alongside `camera` in your `pubspec.yaml`:
+
+```yaml
+dependencies:
+ camera: ^0.11.0
+ camera_desktop: ^1.2.1
+```
+
+That's it. All three desktop platforms are covered, no additional packages needed.
+
+## Usage
+
+Use the standard `camera` package API:
+
+```dart
+import 'package:camera/camera.dart';
+
+final cameras = await availableCameras();
+final controller = CameraController(cameras.first, ResolutionPreset.high);
+await controller.initialize();
+
+// Preview
+CameraPreview(controller);
+
+// Capture
+final file = await controller.takePicture();
+
+// Record
+await controller.startVideoRecording();
+final video = await controller.stopVideoRecording();
+```
+
+### Advanced Settings
+
+`CameraController` (camera 0.11.x+) accepts optional `fps`, `videoBitrate`, and
+`audioBitrate` parameters at construction time:
+
+```dart
+final controller = CameraController(
+ cameras.first,
+ ResolutionPreset.veryHigh,
+ enableAudio: true,
+ fps: 30,
+ videoBitrate: 5000000, // 5 Mbps
+ audioBitrate: 128000, // 128 kbps
+);
+```
+
+These settings are applied during `initialize()`. To change them you must
+`dispose()` the controller and create a new one, see [Limitations](#limitations).
+
+## Platform-Specific Setup
+
+### Linux
+
+Install GStreamer development libraries:
+
+```bash
+# Ubuntu/Debian
+sudo apt install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good
+
+# Fedora
+sudo dnf install gstreamer1-devel gstreamer1-plugins-base-devel gstreamer1-plugins-good
+
+# Arch
+sudo pacman -S gstreamer gst-plugins-base gst-plugins-good
+```
+
+### macOS
+
+Add camera and microphone usage descriptions to your `Info.plist`:
+
+```xml
+NSCameraUsageDescription
+This app needs camera access.
+NSMicrophoneUsageDescription
+This app needs microphone access for video recording.
+```
+
+For sandboxed apps, add to your entitlements:
+
+```xml
+com.apple.security.device.camera
+
+com.apple.security.device.audio-input
+
+```
+
+### Windows
+
+No additional setup required.
+
+## Features
+
+| Feature | Linux | macOS | Windows |
+|---------|-------|-------|---------|
+| Camera enumeration | Yes | Yes | Yes |
+| Live preview | Yes | Yes | Yes |
+| Photo capture | Yes | Yes | Yes |
+| Video recording | Yes | Yes | Yes |
+| Image streaming | Yes | Yes | Yes |
+| Audio recording | Yes | Yes | Yes |
+| Resolution presets | Yes | Yes | Yes |
+| Custom FPS | Yes | Yes | Yes |
+| Video bitrate control | Yes | Yes | Yes |
+| Audio bitrate control | Yes | Yes | Yes |
+| Mirror control | Yes | Yes | No (handled in Flutter) |
+
+## Mirror / Flip Behavior
+
+On **macOS** and **Linux**, the preview frames are mirrored at the native capture
+level (like a webcam selfie view), so `buildPreview()` returns the texture as-is.
+The mirror state can be toggled at runtime via `setMirror()`:
+
+```dart
+import 'package:camera_desktop/camera_desktop.dart';
+
+// Toggle mirror at runtime (macOS & Linux only)
+final plugin = CameraDesktopPlugin();
+await plugin.setMirror(cameraId, false); // disable mirror
+await plugin.setMirror(cameraId, true); // re-enable mirror
+```
+
+On **Windows**, the native backend does not mirror, so the example app wraps the
+preview in a horizontal `Transform` in Flutter:
+
+```dart
+if (Platform.isWindows) {
+ return Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.diagonal3Values(-1, 1, 1),
+ child: Texture(textureId: textureId),
+ );
+}
+```
+
+The same applies to video playback. Recorded files from macOS/Linux are already
+mirrored, while Windows recordings need a Flutter-side flip if you want a
+mirror-style playback.
+
+## Platform Capabilities
+
+Query what the current platform supports at runtime:
+
+```dart
+import 'package:camera_desktop/camera_desktop.dart';
+
+final caps = await CameraDesktopPlugin().getPlatformCapabilities();
+// caps['supportsMirrorControl'] == true (macOS & Linux)
+// caps['supportsVideoFpsControl'] == true
+// caps['supportsVideoBitrateControl'] == true
+// caps['supportsAudioBitrateControl'] == true
+```
+
+This is useful when building UIs that conditionally expose controls based on the
+running platform.
+
+## Limitations
+
+Desktop cameras generally do not support mobile-oriented features:
+
+- Flash/torch control
+- Exposure/focus point selection
+- Zoom (beyond 1.0x)
+- Device orientation changes
+- Pause/resume video recording
+
+These methods either no-op or throw `CameraException` as appropriate.
+
+`fps`, `videoBitrate`, and `audioBitrate` are applied at initialization and cannot
+be changed on a running controller. To update them, `dispose()` the controller and
+create a new one with the desired settings.
diff --git a/plugins/camera_desktop/analysis_options.yaml b/plugins/camera_desktop/analysis_options.yaml
new file mode 100644
index 0000000..a5744c1
--- /dev/null
+++ b/plugins/camera_desktop/analysis_options.yaml
@@ -0,0 +1,4 @@
+include: package:flutter_lints/flutter.yaml
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/plugins/camera_desktop/android/build.gradle b/plugins/camera_desktop/android/build.gradle
new file mode 100644
index 0000000..77b64a2
--- /dev/null
+++ b/plugins/camera_desktop/android/build.gradle
@@ -0,0 +1,48 @@
+group = "com.hugocornellier.camera_desktop"
+version = "1.0-SNAPSHOT"
+
+buildscript {
+ ext.kotlin_version = "2.1.0"
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.9.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: "com.android.library"
+apply plugin: "kotlin-android"
+
+android {
+ namespace = "com.hugocornellier.camera_desktop"
+
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11
+ }
+
+ sourceSets {
+ main.java.srcDirs += "src/main/kotlin"
+ }
+
+ defaultConfig {
+ minSdk = 24
+ }
+}
diff --git a/plugins/camera_desktop/android/src/main/AndroidManifest.xml b/plugins/camera_desktop/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..dba8dcb
--- /dev/null
+++ b/plugins/camera_desktop/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/plugins/camera_desktop/android/src/main/kotlin/com/hugocornellier/camera_desktop/CameraDesktopPlugin.kt b/plugins/camera_desktop/android/src/main/kotlin/com/hugocornellier/camera_desktop/CameraDesktopPlugin.kt
new file mode 100644
index 0000000..c4db6c5
--- /dev/null
+++ b/plugins/camera_desktop/android/src/main/kotlin/com/hugocornellier/camera_desktop/CameraDesktopPlugin.kt
@@ -0,0 +1,34 @@
+package com.hugocornellier.camera_desktop
+
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+import io.flutter.plugin.common.MethodChannel.Result
+
+/** CameraDesktopPlugin */
+class CameraDesktopPlugin :
+ FlutterPlugin,
+ MethodCallHandler {
+ private lateinit var channel: MethodChannel
+
+ override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
+ channel = MethodChannel(flutterPluginBinding.binaryMessenger, "camera_desktop")
+ channel.setMethodCallHandler(this)
+ }
+
+ override fun onMethodCall(
+ call: MethodCall,
+ result: Result
+ ) {
+ if (call.method == "getPlatformVersion") {
+ result.success("Android ${android.os.Build.VERSION.RELEASE}")
+ } else {
+ result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ channel.setMethodCallHandler(null)
+ }
+}
diff --git a/plugins/camera_desktop/ci/check_unicode_inventory.py b/plugins/camera_desktop/ci/check_unicode_inventory.py
new file mode 100755
index 0000000..cb1b748
--- /dev/null
+++ b/plugins/camera_desktop/ci/check_unicode_inventory.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""Assert that every non-ASCII character appearing in windows/*.cpp|*.h also
+appears in ci/cp936_repro.cpp.
+
+Purpose: the CP936 simulation step in CI compiles cp936_repro.cpp to prove that
+/utf-8 resolves C4819 for the exact character set we use. If a new file adds a
+new Unicode character (e.g. a µ in a comment) without updating the synthetic
+repro, CI would silently keep passing while real Simplified-Chinese Windows
+hosts would start failing again.
+
+Run from the repo root. Exits non-zero with a clear message if drift is found.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import sys
+
+# Windows runners default stdout to CP1252, which cannot encode characters
+# like → or ↔. Force UTF-8 so the diagnostic prints below never raise.
+try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+except Exception:
+ pass
+
+
+def non_ascii_chars(path: pathlib.Path) -> set[str]:
+ return {c for c in path.read_text(encoding="utf-8") if ord(c) > 0x7F}
+
+
+def main() -> int:
+ windows_sources = sorted(
+ list(pathlib.Path("windows").glob("*.cpp"))
+ + list(pathlib.Path("windows").glob("*.h"))
+ )
+ if not windows_sources:
+ print("::error::No windows/*.cpp|*.h files found, run from repo root.")
+ return 1
+
+ real: set[str] = set()
+ per_file: dict[str, set[str]] = {}
+ for f in windows_sources:
+ chars = non_ascii_chars(f)
+ if chars:
+ per_file[str(f)] = chars
+ real |= chars
+
+ synthetic_path = pathlib.Path("ci/cp936_repro.cpp")
+ if not synthetic_path.exists():
+ print(f"::error::{synthetic_path} missing.")
+ return 1
+
+ synthetic = non_ascii_chars(synthetic_path)
+
+ missing = real - synthetic
+ if missing:
+ print("::error::ci/cp936_repro.cpp is missing characters used in windows/ sources.")
+ print("Missing:")
+ for c in sorted(missing):
+ sources = [f for f, cs in per_file.items() if c in cs]
+ print(f" U+{ord(c):04X} {c!r} (in: {', '.join(sources)})")
+ print()
+ print("Fix: add these characters to ci/cp936_repro.cpp so the CP936 CI")
+ print("simulation stays representative of the real sources.")
+ return 1
+
+ print(f"OK: all {len(real)} non-ASCII chars in windows/ are covered by {synthetic_path}.")
+ for c in sorted(real):
+ print(f" U+{ord(c):04X} {c}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/plugins/camera_desktop/ci/cp936_repro.cpp b/plugins/camera_desktop/ci/cp936_repro.cpp
new file mode 100644
index 0000000..5623835
--- /dev/null
+++ b/plugins/camera_desktop/ci/cp936_repro.cpp
@@ -0,0 +1,25 @@
+// Synthetic CP936-reproduction file for CI.
+//
+// This file deliberately contains every non-ASCII character that appears in
+// windows/*.cpp and windows/*.h, so that compiling it with
+// cl /c /WX /source-charset:.936 /execution-charset:.936
+// reproduces the exact C4819 / C2220 failure that users see on Simplified
+// Chinese Windows hosts (where GetACP() == 936 / GBK).
+//
+// DO NOT add a BOM to this file. With a BOM, MSVC auto-detects UTF-8 and
+// ignores /source-charset:.936, which would defeat the test.
+//
+// If you add a new non-ASCII character anywhere under windows/, the CI step
+// `ci/check_unicode_inventory.py` will fail until you add that character
+// here. Keep the inventory below in sync.
+//
+// Covered characters (also listed explicitly so a byte-level grep for the
+// UTF-8 sequences finds them here):
+// U+2026 HORIZONTAL ELLIPSIS …
+// U+2192 RIGHTWARDS ARROW →
+// U+2194 LEFT RIGHT ARROW ↔
+// U+2264 LESS-THAN OR EQUAL TO ≤
+// U+2500 BOX DRAWINGS LIGHT HORIZONTAL ─
+//
+// No code needed: /c (compile only) is sufficient to trigger C4819 on the
+// comment bytes above.
diff --git a/plugins/camera_desktop/example/.gitignore b/plugins/camera_desktop/example/.gitignore
new file mode 100644
index 0000000..3820a95
--- /dev/null
+++ b/plugins/camera_desktop/example/.gitignore
@@ -0,0 +1,45 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
diff --git a/plugins/camera_desktop/example/.metadata b/plugins/camera_desktop/example/.metadata
new file mode 100644
index 0000000..9be2f91
--- /dev/null
+++ b/plugins/camera_desktop/example/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ - platform: macos
+ create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+ base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/plugins/camera_desktop/example/README.md b/plugins/camera_desktop/example/README.md
new file mode 100644
index 0000000..f754f1b
--- /dev/null
+++ b/plugins/camera_desktop/example/README.md
@@ -0,0 +1,17 @@
+# camera_desktop_example
+
+Demonstrates how to use the camera_desktop plugin.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/plugins/camera_desktop/example/analysis_options.yaml b/plugins/camera_desktop/example/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/plugins/camera_desktop/example/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/plugins/camera_desktop/example/integration_test/image_stream_dispose_test.dart b/plugins/camera_desktop/example/integration_test/image_stream_dispose_test.dart
new file mode 100644
index 0000000..5f0fcc1
--- /dev/null
+++ b/plugins/camera_desktop/example/integration_test/image_stream_dispose_test.dart
@@ -0,0 +1,101 @@
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:camera_desktop/camera_desktop.dart';
+import 'package:camera_desktop/src/image_stream_ffi.dart';
+
+/// End-to-end proof for the orphaned-poller fix, using the REAL camera + real
+/// FFI poller.
+///
+/// Scenario: an app starts an image stream and then disposes the camera WITHOUT
+/// cancelling the stream subscription (exactly what `CameraController.dispose()`
+/// does — it never stops image streams). Before the fix, the 8ms FFI poll timer
+/// kept firing forever after the camera was gone. After the fix, `dispose()`
+/// stops it.
+///
+/// We observe the real `ImageStreamFfi.pollCount` (number of poll ticks) before
+/// and after `dispose()`: it must stop advancing.
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets(
+ 'disposing a streaming camera stops the FFI poll timer (no leak)',
+ (WidgetTester tester) async {
+ final plugin = CameraPlatform.instance as CameraDesktopPlugin;
+
+ // Capture the real FFI poller created for this stream (the default
+ // factory is ImageStreamFfi.tryCreate, which succeeds in a real build).
+ ImageStreamFfi? realPoller;
+ final defaultFactory = plugin.imageStreamPollerFactory;
+ plugin.imageStreamPollerFactory = (handle) {
+ final poller = defaultFactory(handle);
+ realPoller = poller as ImageStreamFfi?;
+ return poller;
+ };
+ addTearDown(() => plugin.imageStreamPollerFactory = defaultFactory);
+
+ final cameras = await plugin.availableCameras();
+ // ignore: avoid_print
+ print('[poller-leak-test] cameras found: ${cameras.length}');
+ if (cameras.isEmpty) {
+ markTestSkipped('No camera available on this machine.');
+ return;
+ }
+
+ final cameraId = await plugin.createCameraWithSettings(
+ cameras.first,
+ const MediaSettings(resolutionPreset: ResolutionPreset.low),
+ );
+
+ try {
+ await plugin.initializeCamera(cameraId);
+ } on CameraException catch (e) {
+ await plugin.dispose(cameraId);
+ markTestSkipped('Camera initialize failed: ${e.code} ${e.description}');
+ return;
+ }
+
+ // Start streaming. Critically: DO NOT cancel this subscription.
+ final sub = plugin.onStreamedFrameAvailable(cameraId).listen((_) {});
+
+ // Allow onListen (startImageStream round-trip) + several poll ticks.
+ await tester.pump();
+ await Future.delayed(const Duration(milliseconds: 500));
+
+ expect(
+ realPoller,
+ isNotNull,
+ reason: 'FFI fast path should be active in a real macOS build',
+ );
+ final pollsWhileStreaming = realPoller!.pollCount;
+ expect(
+ pollsWhileStreaming,
+ greaterThan(0),
+ reason: 'the poll timer should be running while streaming',
+ );
+
+ // Dispose the camera WITHOUT cancelling the subscription — the leak path.
+ await plugin.dispose(cameraId);
+
+ final pollsAtDispose = realPoller!.pollCount;
+ await Future.delayed(const Duration(milliseconds: 600));
+ final pollsAfterWait = realPoller!.pollCount;
+
+ // ignore: avoid_print
+ print(
+ '[poller-leak-test] polls: whileStreaming=$pollsWhileStreaming '
+ 'atDispose=$pollsAtDispose afterWait(+600ms)=$pollsAfterWait',
+ );
+
+ expect(
+ pollsAfterWait,
+ equals(pollsAtDispose),
+ reason: 'after dispose the poll timer MUST be stopped; before the fix '
+ 'it keeps firing ~125x/sec forever',
+ );
+
+ await sub.cancel();
+ },
+ timeout: const Timeout(Duration(seconds: 90)),
+ );
+}
diff --git a/plugins/camera_desktop/example/integration_test/plugin_integration_test.dart b/plugins/camera_desktop/example/integration_test/plugin_integration_test.dart
new file mode 100644
index 0000000..6a6dbbd
--- /dev/null
+++ b/plugins/camera_desktop/example/integration_test/plugin_integration_test.dart
@@ -0,0 +1,21 @@
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_test.dart';
+import 'package:camera_desktop/camera_desktop.dart';
+
+void main() {
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('CameraDesktopPlugin is registered as CameraPlatform', (
+ WidgetTester tester,
+ ) async {
+ // On Linux, CameraDesktopPlugin should be auto-registered via registerWith().
+ expect(CameraPlatform.instance, isA());
+ });
+
+ testWidgets('availableCameras returns a list', (WidgetTester tester) async {
+ final cameras = await CameraPlatform.instance.availableCameras();
+ // On a machine with no cameras this may be empty, but it shouldn't throw.
+ expect(cameras, isA>());
+ });
+}
diff --git a/plugins/camera_desktop/example/lib/gallery_page.dart b/plugins/camera_desktop/example/lib/gallery_page.dart
new file mode 100644
index 0000000..c6afafc
--- /dev/null
+++ b/plugins/camera_desktop/example/lib/gallery_page.dart
@@ -0,0 +1,155 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+
+import 'photo_viewer_page.dart';
+import 'recent_media.dart';
+import 'video_player_page.dart';
+
+class GalleryPage extends StatelessWidget {
+ const GalleryPage({super.key, required this.items});
+
+ final List items;
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(
+ 'Gallery (${items.length} of ${RecentMediaStore.maxItems})',
+ ),
+ ),
+ body: Column(
+ children: [
+ _buildBanner(context),
+ Expanded(
+ child: items.isEmpty ? _buildEmptyState() : _buildGrid(context),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildBanner(BuildContext context) {
+ final theme = Theme.of(context);
+ return Container(
+ width: double.infinity,
+ margin: const EdgeInsets.all(12),
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.info_outline,
+ size: 20,
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ const SizedBox(width: 12),
+ Text(
+ 'STORES MOST RECENT ${RecentMediaStore.maxItems} ITEMS ONLY',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildEmptyState() {
+ return const Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.photo_library_outlined, size: 64, color: Colors.grey),
+ SizedBox(height: 16),
+ Text(
+ 'No photos or videos yet',
+ style: TextStyle(color: Colors.grey, fontSize: 16),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildGrid(BuildContext context) {
+ final width = MediaQuery.sizeOf(context).width;
+ final crossAxisCount = width > 900
+ ? 4
+ : width > 600
+ ? 3
+ : 2;
+
+ return GridView.builder(
+ padding: const EdgeInsets.all(8),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: crossAxisCount,
+ crossAxisSpacing: 4,
+ mainAxisSpacing: 4,
+ ),
+ itemCount: items.length,
+ itemBuilder: (context, index) {
+ final item = items[index];
+ return GestureDetector(
+ onTap: () => _onItemTap(context, item, index),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: item.isVideo
+ ? _buildVideoThumbnail()
+ : _buildPhotoThumbnail(item),
+ ),
+ );
+ },
+ );
+ }
+
+ void _onItemTap(BuildContext context, MediaEntry item, int index) {
+ if (item.isVideo) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => VideoPlayerPage(path: item.path)),
+ );
+ } else {
+ // Collect only photo entries and find the adjusted index.
+ final photoItems = items.where((e) => e.isPhoto).toList();
+ final photoIndex = photoItems.indexOf(item);
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => PhotoViewerPage(
+ items: photoItems,
+ initialIndex: photoIndex >= 0 ? photoIndex : 0,
+ ),
+ ),
+ );
+ }
+ }
+
+ Widget _buildVideoThumbnail() {
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Container(color: Colors.grey.shade800),
+ const Center(
+ child: Icon(Icons.play_circle_fill, size: 48, color: Colors.white70),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildPhotoThumbnail(MediaEntry item) {
+ return Image.file(
+ File(item.path),
+ fit: BoxFit.cover,
+ cacheWidth: 300,
+ errorBuilder: (_, error, stackTrace) => Container(
+ color: Colors.grey.shade200,
+ child: const Icon(Icons.broken_image, color: Colors.grey),
+ ),
+ );
+ }
+}
diff --git a/plugins/camera_desktop/example/lib/main.dart b/plugins/camera_desktop/example/lib/main.dart
new file mode 100644
index 0000000..9ab4594
--- /dev/null
+++ b/plugins/camera_desktop/example/lib/main.dart
@@ -0,0 +1,719 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:camera/camera.dart';
+import 'package:flutter/material.dart';
+import 'package:media_kit/media_kit.dart';
+
+import 'gallery_page.dart';
+import 'photo_viewer_page.dart';
+import 'recent_media.dart';
+import 'video_player_page.dart';
+
+void main() {
+ WidgetsFlutterBinding.ensureInitialized();
+ MediaKit.ensureInitialized();
+ runApp(const CameraExampleApp());
+}
+
+class CameraExampleApp extends StatelessWidget {
+ const CameraExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'Camera Desktop Example',
+ // Workaround for an upstream Flutter Linux freeze (flutter/flutter#153560,
+ // still open): the GTK/AT-SPI accessibility bridge blocks the UI thread on
+ // a synchronous D-Bus call while walking the semantics tree when an
+ // AT-SPI client is active (e.g. on settings changes here). Wrapping the
+ // Navigator in ExcludeSemantics prunes the app's accessibility tree,
+ // including overlay routes like dropdown popups, which avoids the hang.
+ // Trade-off: in-app screen-reader support is disabled. Remove this once
+ // the upstream bug is fixed if you need accessibility.
+ builder: (context, child) => ExcludeSemantics(child: child!),
+ home: const CameraExamplePage(),
+ );
+ }
+}
+
+enum CaptureMode { photo, video }
+
+const List kFpsOptions = [15, 24, 30, 60];
+
+const List<({int value, String label})> kVideoBitrateOptions = [
+ (value: 1000000, label: '1 Mbps'),
+ (value: 2500000, label: '2.5 Mbps'),
+ (value: 5000000, label: '5 Mbps'),
+ (value: 10000000, label: '10 Mbps'),
+ (value: 20000000, label: '20 Mbps'),
+];
+
+const List<({int value, String label})> kAudioBitrateOptions = [
+ (value: 64000, label: '64 kbps'),
+ (value: 128000, label: '128 kbps'),
+ (value: 192000, label: '192 kbps'),
+ (value: 256000, label: '256 kbps'),
+];
+
+class CameraExamplePage extends StatefulWidget {
+ const CameraExamplePage({super.key});
+
+ @override
+ State createState() => _CameraExamplePageState();
+}
+
+class _CameraExamplePageState extends State {
+ CameraController? _controller;
+ List _cameras = [];
+ String? _errorMessage;
+ bool _isInitialized = false;
+ bool _isCapturing = false;
+
+ CaptureMode _mode = CaptureMode.photo;
+ bool _isRecording = false;
+ bool _isStoppingRecording = false;
+ Duration _recordingDuration = Duration.zero;
+ Timer? _recordingTimer;
+ int _nextPendingVideoId = 1;
+ final List<_PendingVideo> _pendingVideos = <_PendingVideo>[];
+
+ // Settings state, these drive the CameraController constructor.
+ int _selectedCameraIndex = 0;
+ ResolutionPreset _resolutionPreset = ResolutionPreset.veryHigh;
+ int _fps = 30;
+ int _videoBitrate = 5000000;
+ int _audioBitrate = 128000;
+ bool _enableAudio = true;
+ bool _showSettings = false;
+ bool _isReinitializing = false;
+
+ final RecentMediaStore _mediaStore = RecentMediaStore();
+
+ @override
+ void initState() {
+ super.initState();
+ _initCamera();
+ }
+
+ Future _initCamera() async {
+ try {
+ _cameras = await availableCameras();
+ if (_cameras.isEmpty) {
+ setState(() => _errorMessage = 'No cameras found');
+ return;
+ }
+ await _createAndInitController();
+ } on CameraException catch (e) {
+ setState(() => _errorMessage = 'Camera error: ${e.description}');
+ } catch (e) {
+ setState(() => _errorMessage = 'Error: $e');
+ }
+ }
+
+ Future _createAndInitController() async {
+ final controller = CameraController(
+ _cameras[_selectedCameraIndex],
+ _resolutionPreset,
+ enableAudio: _enableAudio,
+ fps: _fps,
+ videoBitrate: _videoBitrate,
+ audioBitrate: _audioBitrate,
+ );
+
+ await controller.initialize();
+ if (!mounted) return;
+
+ setState(() {
+ _controller = controller;
+ _isInitialized = true;
+ _isReinitializing = false;
+ _errorMessage = null;
+ });
+ }
+
+ Future _reinitCamera() async {
+ if (_isRecording || _isStoppingRecording) return;
+
+ setState(() {
+ _isReinitializing = true;
+ _isInitialized = false;
+ });
+
+ await _controller?.dispose();
+ _controller = null;
+
+ try {
+ await _createAndInitController();
+ } on CameraException catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _isReinitializing = false;
+ _errorMessage = 'Camera error: ${e.description}';
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _isReinitializing = false;
+ _errorMessage = 'Error: $e';
+ });
+ }
+ }
+
+ Future _takePicture() async {
+ if (_controller == null || !_isInitialized || _isCapturing) return;
+ setState(() => _isCapturing = true);
+ try {
+ final file = await _controller!.takePicture();
+ if (!mounted) return;
+ _mediaStore.add(file.path, MediaType.photo);
+ setState(() => _errorMessage = null);
+ } on CameraException catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Capture failed: ${e.description}')),
+ );
+ } finally {
+ if (mounted) setState(() => _isCapturing = false);
+ }
+ }
+
+ Future _startRecording() async {
+ if (_controller == null || !_isInitialized || _isRecording) return;
+ try {
+ await _controller!.startVideoRecording();
+ if (!mounted) return;
+ setState(() {
+ _isRecording = true;
+ _recordingDuration = Duration.zero;
+ });
+ _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (mounted) {
+ setState(() => _recordingDuration += const Duration(seconds: 1));
+ }
+ });
+ } on CameraException catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Recording failed: ${e.description}')),
+ );
+ }
+ }
+
+ Future _stopRecording() async {
+ if (!_isRecording || _isStoppingRecording || _controller == null) return;
+ _recordingTimer?.cancel();
+ final pendingId = _nextPendingVideoId++;
+ setState(() {
+ _isRecording = false;
+ _isStoppingRecording = true;
+ _pendingVideos.insert(0, _PendingVideo(id: pendingId));
+ });
+
+ unawaited(_finalizeVideoStop(pendingId));
+ }
+
+ Future _finalizeVideoStop(int pendingId) async {
+ try {
+ final file = await _controller!.stopVideoRecording();
+ if (!mounted) return;
+ _mediaStore.add(file.path, MediaType.video);
+ setState(() {
+ _isStoppingRecording = false;
+ _pendingVideos.removeWhere((p) => p.id == pendingId);
+ });
+ } on CameraException catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _isStoppingRecording = false;
+ _pendingVideos.removeWhere((p) => p.id == pendingId);
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Stop recording failed: ${e.description}')),
+ );
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _isStoppingRecording = false;
+ _pendingVideos.removeWhere((p) => p.id == pendingId);
+ });
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text('Stop recording failed: $e')));
+ }
+ }
+
+ String _formatTimer(Duration d) {
+ final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
+ final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
+ return '$minutes:$seconds';
+ }
+
+ @override
+ void dispose() {
+ _recordingTimer?.cancel();
+ _controller?.dispose();
+ _mediaStore.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Camera Desktop Example'),
+ actions: [
+ IconButton(
+ icon: Icon(
+ _showSettings ? Icons.settings : Icons.settings_outlined,
+ ),
+ tooltip: 'Settings',
+ onPressed: _isRecording
+ ? null
+ : () => setState(() => _showSettings = !_showSettings),
+ ),
+ IconButton(
+ icon: Badge(
+ label: Text('${_mediaStore.count + _pendingVideos.length}'),
+ isLabelVisible:
+ _mediaStore.isNotEmpty || _pendingVideos.isNotEmpty,
+ child: const Icon(Icons.photo_library),
+ ),
+ tooltip:
+ 'Gallery (${_mediaStore.count + _pendingVideos.length}/${RecentMediaStore.maxItems})',
+ onPressed: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => GalleryPage(items: _mediaStore.items),
+ ),
+ ),
+ ),
+ ],
+ ),
+ body: Column(
+ children: [
+ Expanded(child: _buildPreview()),
+ if (_showSettings && _isInitialized) _buildSettingsPanel(),
+ if (_isInitialized) _buildControlBar(),
+ if (_mediaStore.isNotEmpty || _pendingVideos.isNotEmpty)
+ _buildThumbnailStrip(),
+ if (_errorMessage != null && !_isInitialized)
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: Text(
+ _errorMessage!,
+ style: const TextStyle(color: Colors.red),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildPreview() {
+ if (_errorMessage != null && !_isInitialized && !_isReinitializing) {
+ return Center(child: Text(_errorMessage!));
+ }
+ if (!_isInitialized || _controller == null) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ return Stack(
+ children: [
+ Center(child: CameraPreview(_controller!)),
+ if (_isRecording)
+ Positioned(
+ top: 16,
+ left: 0,
+ right: 0,
+ child: Center(
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.black54,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.fiber_manual_record,
+ color: Colors.red,
+ size: 14,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'REC ${_formatTimer(_recordingDuration)}',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ String _resolutionLabel(ResolutionPreset preset) {
+ return switch (preset) {
+ ResolutionPreset.low => 'Low (240p)',
+ ResolutionPreset.medium => 'Medium (480p)',
+ ResolutionPreset.high => 'High (720p)',
+ ResolutionPreset.veryHigh => 'Very High (1080p)',
+ ResolutionPreset.ultraHigh => 'Ultra High (4K)',
+ ResolutionPreset.max => 'Max',
+ };
+ }
+
+ Widget _buildSettingsPanel() {
+ final theme = Theme.of(context);
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest,
+ border: Border(
+ top: BorderSide(color: theme.colorScheme.outlineVariant),
+ ),
+ ),
+ child: Wrap(
+ spacing: 24,
+ runSpacing: 12,
+ children: [
+ if (_cameras.length > 1)
+ _buildDropdownSetting(
+ label: 'Camera',
+ value: _selectedCameraIndex,
+ items: [
+ for (var i = 0; i < _cameras.length; i++)
+ DropdownMenuItem(value: i, child: Text(_cameras[i].name)),
+ ],
+ onChanged: (v) {
+ if (v == null || v == _selectedCameraIndex) return;
+ setState(() => _selectedCameraIndex = v);
+ _reinitCamera();
+ },
+ ),
+ _buildDropdownSetting(
+ label: 'Resolution',
+ value: _resolutionPreset,
+ items: ResolutionPreset.values
+ .map(
+ (p) => DropdownMenuItem(
+ value: p,
+ child: Text(_resolutionLabel(p)),
+ ),
+ )
+ .toList(),
+ onChanged: (v) {
+ if (v == null || v == _resolutionPreset) return;
+ setState(() => _resolutionPreset = v);
+ _reinitCamera();
+ },
+ ),
+ _buildDropdownSetting(
+ label: 'FPS',
+ value: _fps,
+ items: kFpsOptions
+ .map((f) => DropdownMenuItem(value: f, child: Text('$f fps')))
+ .toList(),
+ onChanged: (v) {
+ if (v == null || v == _fps) return;
+ setState(() => _fps = v);
+ _reinitCamera();
+ },
+ ),
+ _buildDropdownSetting(
+ label: 'Video Bitrate',
+ value: _videoBitrate,
+ items: kVideoBitrateOptions
+ .map(
+ (o) => DropdownMenuItem(value: o.value, child: Text(o.label)),
+ )
+ .toList(),
+ onChanged: (v) {
+ if (v == null || v == _videoBitrate) return;
+ setState(() => _videoBitrate = v);
+ _reinitCamera();
+ },
+ ),
+ _buildDropdownSetting(
+ label: 'Audio Bitrate',
+ value: _audioBitrate,
+ items: kAudioBitrateOptions
+ .map(
+ (o) => DropdownMenuItem(value: o.value, child: Text(o.label)),
+ )
+ .toList(),
+ onChanged: (v) {
+ if (v == null || v == _audioBitrate) return;
+ setState(() => _audioBitrate = v);
+ _reinitCamera();
+ },
+ ),
+ _buildSwitchSetting(
+ label: 'Audio',
+ value: _enableAudio,
+ onChanged: (v) {
+ setState(() => _enableAudio = v);
+ _reinitCamera();
+ },
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildDropdownSetting({
+ required String label,
+ required T value,
+ required List> items,
+ required ValueChanged onChanged,
+ }) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ '$label: ',
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
+ ),
+ DropdownButton(
+ value: value,
+ items: items,
+ onChanged: _isReinitializing ? null : onChanged,
+ underline: const SizedBox.shrink(),
+ isDense: true,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildSwitchSetting({
+ required String label,
+ required bool value,
+ required ValueChanged onChanged,
+ }) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ '$label: ',
+ style: Theme.of(
+ context,
+ ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
+ ),
+ Switch(value: value, onChanged: _isReinitializing ? null : onChanged),
+ ],
+ );
+ }
+
+ Widget _buildControlBar() {
+ return Container(
+ padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SegmentedButton(
+ segments: const [
+ ButtonSegment(
+ value: CaptureMode.photo,
+ icon: Icon(Icons.camera_alt),
+ ),
+ ButtonSegment(
+ value: CaptureMode.video,
+ icon: Icon(Icons.videocam),
+ ),
+ ],
+ selected: {_mode},
+ onSelectionChanged: _isRecording
+ ? null
+ : (selection) => setState(() => _mode = selection.first),
+ ),
+ const SizedBox(width: 24),
+ _buildCaptureButton(),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildCaptureButton() {
+ if (_mode == CaptureMode.photo) {
+ return FloatingActionButton(
+ onPressed: _isCapturing ? null : _takePicture,
+ backgroundColor: Colors.white,
+ foregroundColor: Colors.black87,
+ child: _isCapturing
+ ? const SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.camera_alt),
+ );
+ }
+
+ // Video mode
+ if (_isRecording) {
+ return FloatingActionButton(
+ onPressed: _stopRecording,
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ child: const Icon(Icons.stop),
+ );
+ }
+
+ return FloatingActionButton(
+ onPressed: _isStoppingRecording ? null : _startRecording,
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ child: _isStoppingRecording
+ ? const SizedBox(
+ width: 24,
+ height: 24,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ )
+ : const Icon(Icons.fiber_manual_record),
+ );
+ }
+
+ Widget _buildThumbnailStrip() {
+ final items = _mediaStore.items;
+ final pendingCount = _pendingVideos.length;
+ return SizedBox(
+ height: 72,
+ child: ListView.builder(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ itemCount: pendingCount + items.length,
+ itemBuilder: (context, index) {
+ final isPending = index < pendingCount;
+ final item = isPending ? null : items[index - pendingCount];
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: GestureDetector(
+ onTap: () {
+ if (isPending) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Video is still processing. Please wait.'),
+ ),
+ );
+ } else {
+ _onThumbnailTap(item!, index - pendingCount);
+ }
+ },
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: SizedBox(
+ width: 64,
+ height: 64,
+ child: isPending
+ ? _buildPendingVideoThumbnail()
+ : item!.isVideo
+ ? _buildVideoThumbnail()
+ : _buildPhotoThumbnail(item),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ void _onThumbnailTap(MediaEntry item, int index) {
+ if (item.isVideo) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => VideoPlayerPage(path: item.path)),
+ );
+ } else {
+ final photoItems = _mediaStore.items.where((e) => e.isPhoto).toList();
+ final photoIndex = photoItems.indexOf(item);
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => PhotoViewerPage(
+ items: photoItems,
+ initialIndex: photoIndex >= 0 ? photoIndex : 0,
+ ),
+ ),
+ );
+ }
+ }
+
+ Widget _buildVideoThumbnail() {
+ return Container(
+ color: Colors.grey.shade800,
+ child: const Center(
+ child: Icon(Icons.play_circle_fill, size: 28, color: Colors.white70),
+ ),
+ );
+ }
+
+ Widget _buildPendingVideoThumbnail() {
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Container(color: Colors.grey.shade700),
+ const Center(
+ child: SizedBox(
+ width: 22,
+ height: 22,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: Container(
+ color: Colors.black54,
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 2),
+ child: const Text(
+ 'Processing',
+ textAlign: TextAlign.center,
+ style: TextStyle(color: Colors.white, fontSize: 9),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildPhotoThumbnail(MediaEntry item) {
+ return Image.file(
+ File(item.path),
+ width: 64,
+ height: 64,
+ fit: BoxFit.cover,
+ cacheWidth: 120,
+ errorBuilder: (_, error, stackTrace) => Container(
+ width: 64,
+ height: 64,
+ color: Colors.grey.shade300,
+ child: const Icon(Icons.broken_image, size: 20),
+ ),
+ );
+ }
+}
+
+class _PendingVideo {
+ final int id;
+
+ _PendingVideo({required this.id});
+}
diff --git a/plugins/camera_desktop/example/lib/photo_viewer_page.dart b/plugins/camera_desktop/example/lib/photo_viewer_page.dart
new file mode 100644
index 0000000..6a3ebac
--- /dev/null
+++ b/plugins/camera_desktop/example/lib/photo_viewer_page.dart
@@ -0,0 +1,109 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+import 'recent_media.dart';
+
+class PhotoViewerPage extends StatefulWidget {
+ const PhotoViewerPage({
+ super.key,
+ required this.items,
+ required this.initialIndex,
+ });
+
+ final List items;
+ final int initialIndex;
+
+ @override
+ State createState() => _PhotoViewerPageState();
+}
+
+class _PhotoViewerPageState extends State {
+ late final PageController _pageController;
+ late int _currentIndex;
+ late final FocusNode _focusNode;
+
+ @override
+ void initState() {
+ super.initState();
+ _currentIndex = widget.initialIndex.clamp(0, widget.items.length - 1);
+ _pageController = PageController(initialPage: _currentIndex);
+ _focusNode = FocusNode();
+ }
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ void _onKey(KeyEvent event) {
+ if (event is! KeyDownEvent) return;
+ if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
+ if (_currentIndex > 0) {
+ _pageController.animateToPage(
+ _currentIndex - 1,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ }
+ } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
+ if (_currentIndex < widget.items.length - 1) {
+ _pageController.animateToPage(
+ _currentIndex + 1,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ );
+ }
+ } else if (event.logicalKey == LogicalKeyboardKey.escape) {
+ Navigator.of(context).pop();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ title: Text('Photo ${_currentIndex + 1} of ${widget.items.length}'),
+ backgroundColor: Colors.black54,
+ foregroundColor: Colors.white,
+ ),
+ body: KeyboardListener(
+ focusNode: _focusNode,
+ autofocus: true,
+ onKeyEvent: _onKey,
+ child: PageView.builder(
+ controller: _pageController,
+ itemCount: widget.items.length,
+ onPageChanged: (i) => setState(() => _currentIndex = i),
+ itemBuilder: (context, index) {
+ return InteractiveViewer(
+ minScale: 1.0,
+ maxScale: 5.0,
+ child: Center(
+ child: Image.file(
+ File(widget.items[index].path),
+ fit: BoxFit.contain,
+ errorBuilder: (_, error, stackTrace) => const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.broken_image, size: 64, color: Colors.grey),
+ SizedBox(height: 16),
+ Text(
+ 'Photo unavailable',
+ style: TextStyle(color: Colors.grey),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/camera_desktop/example/lib/recent_media.dart b/plugins/camera_desktop/example/lib/recent_media.dart
new file mode 100644
index 0000000..189f6ed
--- /dev/null
+++ b/plugins/camera_desktop/example/lib/recent_media.dart
@@ -0,0 +1,39 @@
+import 'package:flutter/foundation.dart';
+
+enum MediaType { photo, video }
+
+class MediaEntry {
+ final String path;
+ final DateTime capturedAt;
+ final MediaType type;
+
+ MediaEntry({
+ required this.path,
+ required this.capturedAt,
+ required this.type,
+ });
+
+ bool get isVideo => type == MediaType.video;
+ bool get isPhoto => type == MediaType.photo;
+}
+
+class RecentMediaStore extends ChangeNotifier {
+ static const int maxItems = 10;
+ final List _items = [];
+
+ List get items => List.unmodifiable(_items);
+ int get count => _items.length;
+ bool get isEmpty => _items.isEmpty;
+ bool get isNotEmpty => _items.isNotEmpty;
+
+ void add(String path, MediaType type) {
+ _items.insert(
+ 0,
+ MediaEntry(path: path, capturedAt: DateTime.now(), type: type),
+ );
+ if (_items.length > maxItems) {
+ _items.removeLast();
+ }
+ notifyListeners();
+ }
+}
diff --git a/plugins/camera_desktop/example/lib/video_player_page.dart b/plugins/camera_desktop/example/lib/video_player_page.dart
new file mode 100644
index 0000000..a60eb9c
--- /dev/null
+++ b/plugins/camera_desktop/example/lib/video_player_page.dart
@@ -0,0 +1,161 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:media_kit/media_kit.dart';
+import 'package:media_kit_video/media_kit_video.dart';
+
+class VideoPlayerPage extends StatefulWidget {
+ const VideoPlayerPage({super.key, required this.path});
+
+ final String path;
+
+ @override
+ State createState() => _VideoPlayerPageState();
+}
+
+class _VideoPlayerPageState extends State {
+ late final Player _player;
+ late final VideoController _videoController;
+ late final FocusNode _focusNode;
+
+ @override
+ void initState() {
+ super.initState();
+ _player = Player();
+ _videoController = VideoController(_player);
+ _focusNode = FocusNode();
+ _player.open(Media(widget.path));
+ }
+
+ @override
+ void dispose() {
+ _player.dispose();
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ void _onKey(KeyEvent event) {
+ if (event is! KeyDownEvent) return;
+ if (event.logicalKey == LogicalKeyboardKey.escape) {
+ Navigator.of(context).pop();
+ } else if (event.logicalKey == LogicalKeyboardKey.space) {
+ _player.playOrPause();
+ }
+ }
+
+ String _formatDuration(Duration d) {
+ final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0');
+ final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
+ if (d.inHours > 0) {
+ final hours = d.inHours.toString().padLeft(2, '0');
+ return '$hours:$minutes:$seconds';
+ }
+ return '$minutes:$seconds';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.black,
+ appBar: AppBar(
+ title: const Text('Video'),
+ backgroundColor: Colors.black54,
+ foregroundColor: Colors.white,
+ ),
+ body: KeyboardListener(
+ focusNode: _focusNode,
+ autofocus: true,
+ onKeyEvent: _onKey,
+ child: Column(
+ children: [
+ Expanded(
+ child: Platform.isWindows
+ ? Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.diagonal3Values(-1, 1, 1),
+ child: Video(
+ controller: _videoController,
+ fit: BoxFit.contain,
+ ),
+ )
+ : Video(controller: _videoController, fit: BoxFit.contain),
+ ),
+ _buildControls(),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildControls() {
+ return Container(
+ color: Colors.black87,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ StreamBuilder(
+ stream: _player.stream.playing,
+ builder: (context, snap) {
+ final playing = snap.data ?? false;
+ return IconButton(
+ icon: Icon(
+ playing ? Icons.pause : Icons.play_arrow,
+ color: Colors.white,
+ ),
+ onPressed: _player.playOrPause,
+ );
+ },
+ ),
+ StreamBuilder(
+ stream: _player.stream.position,
+ builder: (context, snap) {
+ return Text(
+ _formatDuration(snap.data ?? Duration.zero),
+ style: const TextStyle(color: Colors.white, fontSize: 12),
+ );
+ },
+ ),
+ Expanded(
+ child: StreamBuilder(
+ stream: _player.stream.duration,
+ builder: (context, durSnap) {
+ final duration = durSnap.data ?? Duration.zero;
+ return StreamBuilder(
+ stream: _player.stream.position,
+ builder: (context, posSnap) {
+ final position = posSnap.data ?? Duration.zero;
+ return Slider(
+ value: duration.inMilliseconds > 0
+ ? position.inMilliseconds
+ .clamp(0, duration.inMilliseconds)
+ .toDouble()
+ : 0,
+ max: duration.inMilliseconds > 0
+ ? duration.inMilliseconds.toDouble()
+ : 1,
+ onChanged: (v) {
+ _player.seek(Duration(milliseconds: v.toInt()));
+ },
+ activeColor: Colors.white,
+ inactiveColor: Colors.white24,
+ );
+ },
+ );
+ },
+ ),
+ ),
+ StreamBuilder(
+ stream: _player.stream.duration,
+ builder: (context, snap) {
+ return Text(
+ _formatDuration(snap.data ?? Duration.zero),
+ style: const TextStyle(color: Colors.white, fontSize: 12),
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/plugins/camera_desktop/example/linux/.gitignore b/plugins/camera_desktop/example/linux/.gitignore
new file mode 100644
index 0000000..d3896c9
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/.gitignore
@@ -0,0 +1 @@
+flutter/ephemeral
diff --git a/plugins/camera_desktop/example/linux/CMakeLists.txt b/plugins/camera_desktop/example/linux/CMakeLists.txt
new file mode 100644
index 0000000..63debc1
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/CMakeLists.txt
@@ -0,0 +1,130 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "camera_desktop_example")
+# The unique GTK application identifier for this application. See:
+# https://wiki.gnome.org/HowDoI/ChooseApplicationID
+set(APPLICATION_ID "com.hugocornellier.camera_desktop")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(SET CMP0063 NEW)
+
+# Load bundled libraries from the lib/ directory relative to the binary.
+set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
+
+# Root filesystem for cross-building.
+if(FLUTTER_TARGET_PLATFORM_SYSROOT)
+ set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+ set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+ set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+endif()
+
+# Define build configuration options.
+if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+endif()
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_14)
+ target_compile_options(${TARGET} PRIVATE -Wall -Werror)
+ target_compile_options(${TARGET} PRIVATE "$<$>:-O3>")
+ target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
+
+# Only the install-generated bundle's copy of the executable will launch
+# correctly, since the resources must in the right relative locations. To avoid
+# people trying to run the unbundled copy, put it in a subdirectory instead of
+# the default top-level location.
+set_target_properties(${BINARY_NAME}
+ PROPERTIES
+ RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
+)
+
+# Enable the test target.
+set(include_camera_desktop_tests TRUE)
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# By default, "installing" just makes a relocatable bundle in the build
+# directory.
+set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+# Start with a clean build bundle directory every time.
+install(CODE "
+ file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
+ " COMPONENT Runtime)
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
+ install(FILES "${bundled_library}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endforeach(bundled_library)
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
+ install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
diff --git a/plugins/camera_desktop/example/linux/flutter/CMakeLists.txt b/plugins/camera_desktop/example/linux/flutter/CMakeLists.txt
new file mode 100644
index 0000000..d5bd016
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/flutter/CMakeLists.txt
@@ -0,0 +1,88 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.10)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+
+# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
+# which isn't available in 3.10.
+function(list_prepend LIST_NAME PREFIX)
+ set(NEW_LIST "")
+ foreach(element ${${LIST_NAME}})
+ list(APPEND NEW_LIST "${PREFIX}${element}")
+ endforeach(element)
+ set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
+endfunction()
+
+# === Flutter Library ===
+# System-level dependencies.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
+pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
+pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
+
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "fl_basic_message_channel.h"
+ "fl_binary_codec.h"
+ "fl_binary_messenger.h"
+ "fl_dart_project.h"
+ "fl_engine.h"
+ "fl_json_message_codec.h"
+ "fl_json_method_codec.h"
+ "fl_message_codec.h"
+ "fl_method_call.h"
+ "fl_method_channel.h"
+ "fl_method_codec.h"
+ "fl_method_response.h"
+ "fl_plugin_registrar.h"
+ "fl_plugin_registry.h"
+ "fl_standard_message_codec.h"
+ "fl_standard_method_codec.h"
+ "fl_string_codec.h"
+ "fl_value.h"
+ "fl_view.h"
+ "flutter_linux.h"
+)
+list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
+target_link_libraries(flutter INTERFACE
+ PkgConfig::GTK
+ PkgConfig::GLIB
+ PkgConfig::GIO
+)
+add_dependencies(flutter flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CMAKE_CURRENT_BINARY_DIR}/_phony_
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
+ ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+)
diff --git a/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.cc b/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..37e3b58
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,23 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+#include
+#include
+#include
+
+void fl_register_plugins(FlPluginRegistry* registry) {
+ g_autoptr(FlPluginRegistrar) camera_desktop_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "CameraDesktopPlugin");
+ camera_desktop_plugin_register_with_registrar(camera_desktop_registrar);
+ g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
+ media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
+ g_autoptr(FlPluginRegistrar) media_kit_video_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin");
+ media_kit_video_plugin_register_with_registrar(media_kit_video_registrar);
+}
diff --git a/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.h b/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..e0f0a47
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void fl_register_plugins(FlPluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/plugins/camera_desktop/example/linux/flutter/generated_plugins.cmake b/plugins/camera_desktop/example/linux/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..88984ce
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/flutter/generated_plugins.cmake
@@ -0,0 +1,26 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+ camera_desktop
+ media_kit_libs_linux
+ media_kit_video
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/plugins/camera_desktop/example/linux/runner/CMakeLists.txt b/plugins/camera_desktop/example/linux/runner/CMakeLists.txt
new file mode 100644
index 0000000..e97dabc
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/runner/CMakeLists.txt
@@ -0,0 +1,26 @@
+cmake_minimum_required(VERSION 3.13)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME}
+ "main.cc"
+ "my_application.cc"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the application ID.
+add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
+
+# Add dependency libraries. Add any application-specific dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter)
+target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
+
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
diff --git a/plugins/camera_desktop/example/linux/runner/main.cc b/plugins/camera_desktop/example/linux/runner/main.cc
new file mode 100644
index 0000000..e7c5c54
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/runner/main.cc
@@ -0,0 +1,6 @@
+#include "my_application.h"
+
+int main(int argc, char** argv) {
+ g_autoptr(MyApplication) app = my_application_new();
+ return g_application_run(G_APPLICATION(app), argc, argv);
+}
diff --git a/plugins/camera_desktop/example/linux/runner/my_application.cc b/plugins/camera_desktop/example/linux/runner/my_application.cc
new file mode 100644
index 0000000..eae16a4
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/runner/my_application.cc
@@ -0,0 +1,148 @@
+#include "my_application.h"
+
+#include
+#ifdef GDK_WINDOWING_X11
+#include
+#endif
+
+#include "flutter/generated_plugin_registrant.h"
+
+struct _MyApplication {
+ GtkApplication parent_instance;
+ char** dart_entrypoint_arguments;
+};
+
+G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
+
+// Called when first Flutter frame received.
+static void first_frame_cb(MyApplication* self, FlView* view) {
+ gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
+}
+
+// Implements GApplication::activate.
+static void my_application_activate(GApplication* application) {
+ MyApplication* self = MY_APPLICATION(application);
+ GtkWindow* window =
+ GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
+
+ // Use a header bar when running in GNOME as this is the common style used
+ // by applications and is the setup most users will be using (e.g. Ubuntu
+ // desktop).
+ // If running on X and not using GNOME then just use a traditional title bar
+ // in case the window manager does more exotic layout, e.g. tiling.
+ // If running on Wayland assume the header bar will work (may need changing
+ // if future cases occur).
+ gboolean use_header_bar = TRUE;
+#ifdef GDK_WINDOWING_X11
+ GdkScreen* screen = gtk_window_get_screen(window);
+ if (GDK_IS_X11_SCREEN(screen)) {
+ const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
+ if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
+ use_header_bar = FALSE;
+ }
+ }
+#endif
+ if (use_header_bar) {
+ GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
+ gtk_widget_show(GTK_WIDGET(header_bar));
+ gtk_header_bar_set_title(header_bar, "camera_desktop_example");
+ gtk_header_bar_set_show_close_button(header_bar, TRUE);
+ gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
+ } else {
+ gtk_window_set_title(window, "camera_desktop_example");
+ }
+
+ gtk_window_set_default_size(window, 1280, 720);
+
+ g_autoptr(FlDartProject) project = fl_dart_project_new();
+ fl_dart_project_set_dart_entrypoint_arguments(
+ project, self->dart_entrypoint_arguments);
+
+ FlView* view = fl_view_new(project);
+ GdkRGBA background_color;
+ // Background defaults to black, override it here if necessary, e.g. #00000000
+ // for transparent.
+ gdk_rgba_parse(&background_color, "#000000");
+ fl_view_set_background_color(view, &background_color);
+ gtk_widget_show(GTK_WIDGET(view));
+ gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
+
+ // Show the window when Flutter renders.
+ // Requires the view to be realized so we can start rendering.
+ g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
+ self);
+ gtk_widget_realize(GTK_WIDGET(view));
+
+ fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+
+ gtk_widget_grab_focus(GTK_WIDGET(view));
+}
+
+// Implements GApplication::local_command_line.
+static gboolean my_application_local_command_line(GApplication* application,
+ gchar*** arguments,
+ int* exit_status) {
+ MyApplication* self = MY_APPLICATION(application);
+ // Strip out the first argument as it is the binary name.
+ self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
+
+ g_autoptr(GError) error = nullptr;
+ if (!g_application_register(application, nullptr, &error)) {
+ g_warning("Failed to register: %s", error->message);
+ *exit_status = 1;
+ return TRUE;
+ }
+
+ g_application_activate(application);
+ *exit_status = 0;
+
+ return TRUE;
+}
+
+// Implements GApplication::startup.
+static void my_application_startup(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application startup.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
+}
+
+// Implements GApplication::shutdown.
+static void my_application_shutdown(GApplication* application) {
+ // MyApplication* self = MY_APPLICATION(object);
+
+ // Perform any actions required at application shutdown.
+
+ G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
+}
+
+// Implements GObject::dispose.
+static void my_application_dispose(GObject* object) {
+ MyApplication* self = MY_APPLICATION(object);
+ g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
+ G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
+}
+
+static void my_application_class_init(MyApplicationClass* klass) {
+ G_APPLICATION_CLASS(klass)->activate = my_application_activate;
+ G_APPLICATION_CLASS(klass)->local_command_line =
+ my_application_local_command_line;
+ G_APPLICATION_CLASS(klass)->startup = my_application_startup;
+ G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
+ G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
+}
+
+static void my_application_init(MyApplication* self) {}
+
+MyApplication* my_application_new() {
+ // Set the program name to the application ID, which helps various systems
+ // like GTK and desktop environments map this running application to its
+ // corresponding .desktop file. This ensures better integration by allowing
+ // the application to be recognized beyond its binary name.
+ g_set_prgname(APPLICATION_ID);
+
+ return MY_APPLICATION(g_object_new(my_application_get_type(),
+ "application-id", APPLICATION_ID, "flags",
+ G_APPLICATION_NON_UNIQUE, nullptr));
+}
diff --git a/plugins/camera_desktop/example/linux/runner/my_application.h b/plugins/camera_desktop/example/linux/runner/my_application.h
new file mode 100644
index 0000000..db16367
--- /dev/null
+++ b/plugins/camera_desktop/example/linux/runner/my_application.h
@@ -0,0 +1,21 @@
+#ifndef FLUTTER_MY_APPLICATION_H_
+#define FLUTTER_MY_APPLICATION_H_
+
+#include
+
+G_DECLARE_FINAL_TYPE(MyApplication,
+ my_application,
+ MY,
+ APPLICATION,
+ GtkApplication)
+
+/**
+ * my_application_new:
+ *
+ * Creates a new Flutter-based application.
+ *
+ * Returns: a new #MyApplication.
+ */
+MyApplication* my_application_new();
+
+#endif // FLUTTER_MY_APPLICATION_H_
diff --git a/plugins/camera_desktop/example/macos/.gitignore b/plugins/camera_desktop/example/macos/.gitignore
new file mode 100644
index 0000000..746adbb
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/.gitignore
@@ -0,0 +1,7 @@
+# Flutter-related
+**/Flutter/ephemeral/
+**/Pods/
+
+# Xcode-related
+**/dgph
+**/xcuserdata/
diff --git a/plugins/camera_desktop/example/macos/Flutter/Flutter-Debug.xcconfig b/plugins/camera_desktop/example/macos/Flutter/Flutter-Debug.xcconfig
new file mode 100644
index 0000000..4b81f9b
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Flutter/Flutter-Debug.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/plugins/camera_desktop/example/macos/Flutter/Flutter-Release.xcconfig b/plugins/camera_desktop/example/macos/Flutter/Flutter-Release.xcconfig
new file mode 100644
index 0000000..5caa9d1
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Flutter/Flutter-Release.xcconfig
@@ -0,0 +1,2 @@
+#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
+#include "ephemeral/Flutter-Generated.xcconfig"
diff --git a/plugins/camera_desktop/example/macos/Flutter/GeneratedPluginRegistrant.swift b/plugins/camera_desktop/example/macos/Flutter/GeneratedPluginRegistrant.swift
new file mode 100644
index 0000000..7891e3b
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -0,0 +1,20 @@
+//
+// Generated file. Do not edit.
+//
+
+import FlutterMacOS
+import Foundation
+
+import camera_desktop
+import media_kit_libs_macos_video
+import media_kit_video
+import package_info_plus
+import wakelock_plus
+
+func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+ CameraDesktopPlugin.register(with: registry.registrar(forPlugin: "CameraDesktopPlugin"))
+ MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
+ MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
+ FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
+ WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
+}
diff --git a/plugins/camera_desktop/example/macos/Podfile b/plugins/camera_desktop/example/macos/Podfile
new file mode 100644
index 0000000..d33056c
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Podfile
@@ -0,0 +1,62 @@
+platform :osx, '10.15'
+
+# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+
+project 'Runner', {
+ 'Debug' => :debug,
+ 'Profile' => :release,
+ 'Release' => :release,
+}
+
+def flutter_root
+ generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
+ unless File.exist?(generated_xcode_build_settings_path)
+ raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
+ end
+
+ File.foreach(generated_xcode_build_settings_path) do |line|
+ matches = line.match(/FLUTTER_ROOT\=(.*)/)
+ return matches[1].strip if matches
+ end
+ raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
+end
+
+require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
+
+flutter_macos_podfile_setup
+
+target 'Runner' do
+ use_frameworks!
+
+ flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
+ target 'RunnerTests' do
+ inherit! :search_paths
+ end
+end
+
+post_install do |installer|
+ installer.pods_project.targets.each do |target|
+ flutter_additional_macos_build_settings(target)
+ end
+
+ # media_kit_video ships an xcconfig where GCC_PREPROCESSOR_DEFINITIONS contains
+ # "$(inherited)" twice, which expands the build-config macros (POD_CONFIGURATION_*,
+ # DEBUG) twice and triggers "macro redefined" warnings. Strip the duplicate.
+ Dir.glob(File.join(installer.sandbox.root, '**', '*.xcconfig')).each do |xcconfig|
+ contents = File.read(xcconfig)
+ fixed = contents.gsub(/^(GCC_PREPROCESSOR_DEFINITIONS\s*=\s*)(.*)$/) do
+ prefix, value = $1, $2
+ seen_inherited = false
+ tokens = value.split(/\s+/).reject do |tok|
+ if tok == '$(inherited)' || tok == '"$(inherited)"'
+ next true if seen_inherited
+ seen_inherited = true
+ end
+ false
+ end
+ "#{prefix}#{tokens.join(' ')}"
+ end
+ File.write(xcconfig, fixed) if fixed != contents
+ end
+end
diff --git a/plugins/camera_desktop/example/macos/Podfile.lock b/plugins/camera_desktop/example/macos/Podfile.lock
new file mode 100644
index 0000000..14b1282
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Podfile.lock
@@ -0,0 +1,28 @@
+PODS:
+ - FlutterMacOS (1.0.0)
+ - media_kit_libs_macos_video (1.0.4):
+ - FlutterMacOS
+ - media_kit_video (0.0.1):
+ - FlutterMacOS
+
+DEPENDENCIES:
+ - FlutterMacOS (from `Flutter/ephemeral`)
+ - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
+ - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`)
+
+EXTERNAL SOURCES:
+ FlutterMacOS:
+ :path: Flutter/ephemeral
+ media_kit_libs_macos_video:
+ :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos
+ media_kit_video:
+ :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos
+
+SPEC CHECKSUMS:
+ FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
+ media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65
+ media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758
+
+PODFILE CHECKSUM: d73b3795a102e716fcfac4d702dd615b914e690e
+
+COCOAPODS: 1.16.2
diff --git a/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.pbxproj b/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..2d075c7
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,829 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXAggregateTarget section */
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
+ isa = PBXAggregateTarget;
+ buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
+ buildPhases = (
+ 33CC111E2044C6BF0003C045 /* ShellScript */,
+ );
+ dependencies = (
+ );
+ name = "Flutter Assemble";
+ productName = FLX;
+ };
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; };
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 78EFE35BF83BFD45AEF6B49D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 242FF7202E4D346E25CA6876 /* Pods_RunnerTests.framework */; };
+ CA173EE46481C9CA75E4E823 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA453647A92249E3F85B718 /* Pods_Runner.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC10EC2044A3C60003C045;
+ remoteInfo = Runner;
+ };
+ 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 33CC111A2044C6BA0003C045;
+ remoteInfo = FLX;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 33CC110E2044A8840003C045 /* Bundle Framework */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Bundle Framework";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 242FF7202E4D346E25CA6876 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 2EACE3DA6D35A68C3CD7B732 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; };
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; };
+ 33CC10ED2044A3C60003C045 /* camera_desktop_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = camera_desktop_example.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; };
+ 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; };
+ 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; };
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; };
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; };
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; };
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; };
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; };
+ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; };
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; };
+ 3FA453647A92249E3F85B718 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+ 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; };
+ 78DABEA22ED26510000E7860 /* camera_desktop */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = camera_desktop; path = ../../../macos/camera_desktop; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; };
+ B4CFE74B02A679967C8E566C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ DC48F9159AD5A6E9DC4AE8AF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
+ E384C909A2092068A1672556 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
+ E9DAE22C81AE8ADF1F1D6EE7 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ F0D230BBE672EE22D04C4895 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 331C80D2294CF70F00263BE5 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78EFE35BF83BFD45AEF6B49D /* Pods_RunnerTests.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EA2044A3C60003C045 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ CA173EE46481C9CA75E4E823 /* Pods_Runner.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C80D6294CF71000263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C80D7294CF71000263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 33BA886A226E78AF003329D5 /* Configs */ = {
+ isa = PBXGroup;
+ children = (
+ 33E5194F232828860026EE4D /* AppInfo.xcconfig */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
+ );
+ path = Configs;
+ sourceTree = "";
+ };
+ 33CC10E42044A3C60003C045 = {
+ isa = PBXGroup;
+ children = (
+ 33FAB671232836740065AC1E /* Runner */,
+ 33CEB47122A05771004F2AC0 /* Flutter */,
+ 331C80D6294CF71000263BE5 /* RunnerTests */,
+ 33CC10EE2044A3C60003C045 /* Products */,
+ D9B5B589C9B9197BEF1D9DA1 /* Pods */,
+ 65943964AD6ADD2D768A7FEF /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 33CC10EE2044A3C60003C045 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10ED2044A3C60003C045 /* camera_desktop_example.app */,
+ 331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 33CC11242044D66E0003C045 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F22044A3C60003C045 /* Assets.xcassets */,
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */,
+ 33CC10F72044A3C60003C045 /* Info.plist */,
+ );
+ name = Resources;
+ path = ..;
+ sourceTree = "";
+ };
+ 33CEB47122A05771004F2AC0 /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78DABEA22ED26510000E7860 /* camera_desktop */,
+ 784666492D4C4C64000A1A5F /* FlutterFramework */,
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
+ 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
+ 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
+ 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
+ );
+ path = Flutter;
+ sourceTree = "";
+ };
+ 33FAB671232836740065AC1E /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 33CC10F02044A3C60003C045 /* AppDelegate.swift */,
+ 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
+ 33E51913231747F40026EE4D /* DebugProfile.entitlements */,
+ 33E51914231749380026EE4D /* Release.entitlements */,
+ 33CC11242044D66E0003C045 /* Resources */,
+ 33BA886A226E78AF003329D5 /* Configs */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+ 65943964AD6ADD2D768A7FEF /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 3FA453647A92249E3F85B718 /* Pods_Runner.framework */,
+ 242FF7202E4D346E25CA6876 /* Pods_RunnerTests.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ D9B5B589C9B9197BEF1D9DA1 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ E9DAE22C81AE8ADF1F1D6EE7 /* Pods-Runner.debug.xcconfig */,
+ DC48F9159AD5A6E9DC4AE8AF /* Pods-Runner.release.xcconfig */,
+ F0D230BBE672EE22D04C4895 /* Pods-Runner.profile.xcconfig */,
+ B4CFE74B02A679967C8E566C /* Pods-RunnerTests.debug.xcconfig */,
+ E384C909A2092068A1672556 /* Pods-RunnerTests.release.xcconfig */,
+ 2EACE3DA6D35A68C3CD7B732 /* Pods-RunnerTests.profile.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C80D4294CF70F00263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 75DFE264BFBC588855DF7735 /* [CP] Check Pods Manifest.lock */,
+ 331C80D1294CF70F00263BE5 /* Sources */,
+ 331C80D2294CF70F00263BE5 /* Frameworks */,
+ 331C80D3294CF70F00263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 33CC10EC2044A3C60003C045 /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 66940AF51424E4C81651F1C7 /* [CP] Check Pods Manifest.lock */,
+ 33CC10E92044A3C60003C045 /* Sources */,
+ 33CC10EA2044A3C60003C045 /* Frameworks */,
+ 33CC10EB2044A3C60003C045 /* Resources */,
+ 33CC110E2044A8840003C045 /* Bundle Framework */,
+ 3399D490228B24CF009A79C7 /* ShellScript */,
+ 267A2FD4C05E3C94C9133090 /* [CP] Embed Pods Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */,
+ );
+ name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
+ productName = Runner;
+ productReference = 33CC10ED2044A3C60003C045 /* camera_desktop_example.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 33CC10E52044A3C60003C045 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastSwiftUpdateCheck = 0920;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C80D4294CF70F00263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 33CC10EC2044A3C60003C045;
+ };
+ 33CC10EC2044A3C60003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ LastSwiftMigration = 1100;
+ ProvisioningStyle = Automatic;
+ SystemCapabilities = {
+ com.apple.Sandbox = {
+ enabled = 1;
+ };
+ };
+ };
+ 33CC111A2044C6BA0003C045 = {
+ CreatedOnToolsVersion = 9.2;
+ ProvisioningStyle = Manual;
+ };
+ };
+ };
+ buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 33CC10E42044A3C60003C045;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
+ productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 33CC10EC2044A3C60003C045 /* Runner */,
+ 331C80D4294CF70F00263BE5 /* RunnerTests */,
+ 33CC111A2044C6BA0003C045 /* Flutter Assemble */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C80D3294CF70F00263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10EB2044A3C60003C045 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
+ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 267A2FD4C05E3C94C9133090 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 3399D490228B24CF009A79C7 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
+ };
+ 33CC111E2044C6BF0003C045 /* ShellScript */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ Flutter/ephemeral/FlutterInputs.xcfilelist,
+ );
+ inputPaths = (
+ Flutter/ephemeral/tripwire,
+ );
+ outputFileListPaths = (
+ Flutter/ephemeral/FlutterOutputs.xcfilelist,
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
+ };
+ 66940AF51424E4C81651F1C7 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+ 75DFE264BFBC588855DF7735 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ showEnvVarsInLog = 0;
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C80D1294CF70F00263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 33CC10E92044A3C60003C045 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
+ 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
+ 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC10EC2044A3C60003C045 /* Runner */;
+ targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */;
+ };
+ 33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
+ targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 33CC10F52044A3C60003C045 /* Base */,
+ );
+ name = MainMenu.xib;
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 331C80DB294CF71000263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = B4CFE74B02A679967C8E566C /* Pods-RunnerTests.debug.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.cameraDesktopExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/camera_desktop_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/camera_desktop_example";
+ };
+ name = Debug;
+ };
+ 331C80DC294CF71000263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = E384C909A2092068A1672556 /* Pods-RunnerTests.release.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.cameraDesktopExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/camera_desktop_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/camera_desktop_example";
+ };
+ name = Release;
+ };
+ 331C80DD294CF71000263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 2EACE3DA6D35A68C3CD7B732 /* Pods-RunnerTests.profile.xcconfig */;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.example.cameraDesktopExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/camera_desktop_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/camera_desktop_example";
+ };
+ name = Profile;
+ };
+ 338D0CE9231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Profile;
+ };
+ 338D0CEA231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Profile;
+ };
+ 338D0CEB231458BD00FA5F75 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Profile;
+ };
+ 33CC10F92044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 33CC10FA2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CODE_SIGN_IDENTITY = "-";
+ COPY_PHASE_STRIP = NO;
+ DEAD_CODE_STRIPPING = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ };
+ name = Release;
+ };
+ 33CC10FC2044A3C60003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 33CC10FD2044A3C60003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ PROVISIONING_PROFILE_SPECIFIER = "";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 33CC111C2044C6BA0003C045 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Manual;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Debug;
+ };
+ 33CC111D2044C6BA0003C045 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C80DB294CF71000263BE5 /* Debug */,
+ 331C80DC294CF71000263BE5 /* Release */,
+ 331C80DD294CF71000263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10F92044A3C60003C045 /* Debug */,
+ 33CC10FA2044A3C60003C045 /* Release */,
+ 338D0CE9231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC10FC2044A3C60003C045 /* Debug */,
+ 33CC10FD2044A3C60003C045 /* Release */,
+ 338D0CEA231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 33CC111C2044C6BA0003C045 /* Debug */,
+ 33CC111D2044C6BA0003C045 /* Release */,
+ 338D0CEB231458BD00FA5F75 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 33CC10E52044A3C60003C045 /* Project object */;
+}
diff --git a/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/plugins/camera_desktop/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..4cec7fc
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/plugins/camera_desktop/example/macos/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..21a3cc1
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/plugins/camera_desktop/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner/AppDelegate.swift b/plugins/camera_desktop/example/macos/Runner/AppDelegate.swift
new file mode 100644
index 0000000..b3c1761
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Cocoa
+import FlutterMacOS
+
+@main
+class AppDelegate: FlutterAppDelegate {
+ override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ return true
+ }
+
+ override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
+ return true
+ }
+}
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..a2ec33f
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_16.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "16x16",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_32.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "32x32",
+ "idiom" : "mac",
+ "filename" : "app_icon_64.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_128.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "128x128",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_256.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "256x256",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_512.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "512x512",
+ "idiom" : "mac",
+ "filename" : "app_icon_1024.png",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png
new file mode 100644
index 0000000..82b6f9d
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png
new file mode 100644
index 0000000..13b35eb
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png
new file mode 100644
index 0000000..0a3f5fa
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png
new file mode 100644
index 0000000..bdb5722
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png
new file mode 100644
index 0000000..f083318
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png
new file mode 100644
index 0000000..326c0e7
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png
new file mode 100644
index 0000000..2f1632c
Binary files /dev/null and b/plugins/camera_desktop/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ
diff --git a/plugins/camera_desktop/example/macos/Runner/Base.lproj/MainMenu.xib b/plugins/camera_desktop/example/macos/Runner/Base.lproj/MainMenu.xib
new file mode 100644
index 0000000..80e867a
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Base.lproj/MainMenu.xib
@@ -0,0 +1,343 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner/Configs/AppInfo.xcconfig b/plugins/camera_desktop/example/macos/Runner/Configs/AppInfo.xcconfig
new file mode 100644
index 0000000..185334c
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Configs/AppInfo.xcconfig
@@ -0,0 +1,14 @@
+// Application-level settings for the Runner target.
+//
+// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
+// future. If not, the values below would default to using the project name when this becomes a
+// 'flutter create' template.
+
+// The application's name. By default this is also the title of the Flutter window.
+PRODUCT_NAME = camera_desktop_example
+
+// The application's bundle identifier
+PRODUCT_BUNDLE_IDENTIFIER = com.example.cameraDesktopExample
+
+// The copyright displayed in application information
+PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
diff --git a/plugins/camera_desktop/example/macos/Runner/Configs/Debug.xcconfig b/plugins/camera_desktop/example/macos/Runner/Configs/Debug.xcconfig
new file mode 100644
index 0000000..36b0fd9
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Configs/Debug.xcconfig
@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Debug.xcconfig"
+#include "Warnings.xcconfig"
diff --git a/plugins/camera_desktop/example/macos/Runner/Configs/Release.xcconfig b/plugins/camera_desktop/example/macos/Runner/Configs/Release.xcconfig
new file mode 100644
index 0000000..dff4f49
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Configs/Release.xcconfig
@@ -0,0 +1,2 @@
+#include "../../Flutter/Flutter-Release.xcconfig"
+#include "Warnings.xcconfig"
diff --git a/plugins/camera_desktop/example/macos/Runner/Configs/Warnings.xcconfig b/plugins/camera_desktop/example/macos/Runner/Configs/Warnings.xcconfig
new file mode 100644
index 0000000..42bcbf4
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Configs/Warnings.xcconfig
@@ -0,0 +1,13 @@
+WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings
+GCC_WARN_UNDECLARED_SELECTOR = YES
+CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES
+CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE
+CLANG_WARN__DUPLICATE_METHOD_MATCH = YES
+CLANG_WARN_PRAGMA_PACK = YES
+CLANG_WARN_STRICT_PROTOTYPES = YES
+CLANG_WARN_COMMA = YES
+GCC_WARN_STRICT_SELECTOR_MATCH = YES
+CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES
+CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES
+GCC_WARN_SHADOW = YES
+CLANG_WARN_UNREACHABLE_CODE = YES
diff --git a/plugins/camera_desktop/example/macos/Runner/DebugProfile.entitlements b/plugins/camera_desktop/example/macos/Runner/DebugProfile.entitlements
new file mode 100644
index 0000000..f9b6303
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/DebugProfile.entitlements
@@ -0,0 +1,16 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.network.server
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner/Info.plist b/plugins/camera_desktop/example/macos/Runner/Info.plist
new file mode 100644
index 0000000..a173a61
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconFile
+
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSHumanReadableCopyright
+ $(PRODUCT_COPYRIGHT)
+ NSMainNibFile
+ MainMenu
+ NSPrincipalClass
+ NSApplication
+ NSCameraUsageDescription
+ This app needs camera access for the camera preview demo.
+ NSMicrophoneUsageDescription
+ This app needs microphone access for video recording with audio.
+
+
diff --git a/plugins/camera_desktop/example/macos/Runner/MainFlutterWindow.swift b/plugins/camera_desktop/example/macos/Runner/MainFlutterWindow.swift
new file mode 100644
index 0000000..3cc05eb
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/MainFlutterWindow.swift
@@ -0,0 +1,15 @@
+import Cocoa
+import FlutterMacOS
+
+class MainFlutterWindow: NSWindow {
+ override func awakeFromNib() {
+ let flutterViewController = FlutterViewController()
+ let windowFrame = self.frame
+ self.contentViewController = flutterViewController
+ self.setFrame(windowFrame, display: true)
+
+ RegisterGeneratedPlugins(registry: flutterViewController)
+
+ super.awakeFromNib()
+ }
+}
diff --git a/plugins/camera_desktop/example/macos/Runner/Release.entitlements b/plugins/camera_desktop/example/macos/Runner/Release.entitlements
new file mode 100644
index 0000000..fa28801
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/Runner/Release.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/plugins/camera_desktop/example/macos/RunnerTests/RunnerTests.swift b/plugins/camera_desktop/example/macos/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..008b132
--- /dev/null
+++ b/plugins/camera_desktop/example/macos/RunnerTests/RunnerTests.swift
@@ -0,0 +1,101 @@
+import Cocoa
+import FlutterMacOS
+import XCTest
+import AVFoundation
+@testable import camera_desktop
+
+class RunnerTests: XCTestCase {
+
+ func testDeviceEnumeration() {
+ // Should return a list (may be empty on CI/headless machines).
+ let devices = DeviceEnumerator.enumerateDevices()
+ XCTAssertNotNil(devices)
+ }
+
+ func testDeviceIdExtraction() {
+ let name = "FaceTime HD Camera (0x1234567890)"
+ let deviceId = DeviceEnumerator.extractDeviceId(from: name)
+ XCTAssertEqual(deviceId, "0x1234567890")
+ }
+
+ func testDeviceIdExtractionNoParens() {
+ let name = "NoParen"
+ let deviceId = DeviceEnumerator.extractDeviceId(from: name)
+ XCTAssertNil(deviceId)
+ }
+
+ func testDeviceIdExtractionNestedParens() {
+ let name = "Camera (Model X) (ABC123)"
+ let deviceId = DeviceEnumerator.extractDeviceId(from: name)
+ XCTAssertEqual(deviceId, "ABC123")
+ }
+
+ func testSessionPresetMapping() {
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 0), .low)
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 1), .medium)
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 2), .high)
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 3), .hd1280x720)
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 4), .hd1920x1080)
+ XCTAssertEqual(DeviceEnumerator.sessionPreset(for: 5), .hd1920x1080)
+ }
+
+ func testPhotoPathGeneration() {
+ let path = PhotoHandler.generatePath(cameraId: 42)
+ XCTAssertTrue(path.contains("camera_desktop_42_"))
+ XCTAssertTrue(path.hasSuffix(".jpg"))
+ }
+
+ func testRecordPathGeneration() {
+ let path = RecordHandler.generatePath()
+ XCTAssertTrue(path.contains("camera_desktop_video_"))
+ XCTAssertTrue(path.hasSuffix(".mp4"))
+ }
+
+ /// Regression test for the image-stream buffer-release fix.
+ ///
+ /// Before the fix, stopping the image stream left both shared FFI buffers
+ /// allocated until full session disposal. This asserts (deterministically,
+ /// no camera or process-memory measurement required) that releaseBuffers()
+ /// reclaims all buffer memory and that the instance stays reusable afterward.
+ func testImageStreamBuffersReleasedOnStop() {
+ let width = 640
+ let height = 480
+
+ var pb: CVPixelBuffer?
+ let status = CVPixelBufferCreate(
+ kCFAllocatorDefault, width, height,
+ kCVPixelFormatType_32BGRA, [:] as CFDictionary, &pb)
+ XCTAssertEqual(status, kCVReturnSuccess)
+ guard let pixelBuffer = pb else {
+ XCTFail("Failed to create test pixel buffer")
+ return
+ }
+
+ CVPixelBufferLockBaseAddress(pixelBuffer, [])
+ let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
+ if let base = CVPixelBufferGetBaseAddress(pixelBuffer) {
+ memset(base, 0x7F, bytesPerRow * height)
+ }
+ CVPixelBufferUnlockBaseAddress(pixelBuffer, [])
+ let perBuffer = ImageStreamFFI.headerSize + bytesPerRow * height
+
+ let ffi = ImageStreamFFI()
+ XCTAssertEqual(ffi.allocatedByteCount, 0, "starts with nothing allocated")
+
+ ffi.writeFrame(pixelBuffer: pixelBuffer, cameraId: 1)
+ ffi.writeFrame(pixelBuffer: pixelBuffer, cameraId: 1)
+ XCTAssertEqual(ffi.allocatedByteCount, 2 * perBuffer,
+ "both shared buffers are allocated while streaming")
+ XCTAssertNotNil(ffi.getBufferPointer(), "front buffer is readable while streaming")
+
+ // The fix under test: stopping the stream reclaims all buffer memory.
+ ffi.releaseBuffers()
+ XCTAssertEqual(ffi.allocatedByteCount, 0, "releaseBuffers() reclaims all buffer memory")
+ XCTAssertNil(ffi.getBufferPointer(), "no front buffer after release")
+
+ // The instance remains usable: a later frame re-allocates lazily.
+ ffi.writeFrame(pixelBuffer: pixelBuffer, cameraId: 1)
+ XCTAssertEqual(ffi.allocatedByteCount, perBuffer,
+ "one buffer is re-allocated after release")
+ }
+}
diff --git a/plugins/camera_desktop/example/pubspec.lock b/plugins/camera_desktop/example/pubspec.lock
new file mode 100644
index 0000000..0c4ff28
--- /dev/null
+++ b/plugins/camera_desktop/example/pubspec.lock
@@ -0,0 +1,576 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+ archive:
+ dependency: transitive
+ description:
+ name: archive
+ sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.0.9"
+ args:
+ dependency: transitive
+ description:
+ name: args
+ sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.7.0"
+ async:
+ dependency: transitive
+ description:
+ name: async
+ sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.13.0"
+ boolean_selector:
+ dependency: transitive
+ description:
+ name: boolean_selector
+ sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
+ camera:
+ dependency: "direct main"
+ description:
+ name: camera
+ sha256: "034c38cb8014d29698dcae6d20276688a1bf74e6487dfeb274d70ea05d5f7777"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.0+1"
+ camera_android_camerax:
+ dependency: transitive
+ description:
+ name: camera_android_camerax
+ sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.2"
+ camera_avfoundation:
+ dependency: transitive
+ description:
+ name: camera_avfoundation
+ sha256: "90e4cc3fde331581a3b2d35d83be41dbb7393af0ab857eb27b732174289cb96d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.10.1"
+ camera_desktop:
+ dependency: "direct main"
+ description:
+ path: ".."
+ relative: true
+ source: path
+ version: "1.2.1"
+ camera_platform_interface:
+ dependency: "direct main"
+ description:
+ name: camera_platform_interface
+ sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.12.0"
+ camera_web:
+ dependency: transitive
+ description:
+ name: camera_web
+ sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5+3"
+ characters:
+ dependency: transitive
+ description:
+ name: characters
+ sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ clock:
+ dependency: transitive
+ description:
+ name: clock
+ sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.2"
+ collection:
+ dependency: transitive
+ description:
+ name: collection
+ sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.19.1"
+ cross_file:
+ dependency: transitive
+ description:
+ name: cross_file
+ sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.5+2"
+ crypto:
+ dependency: transitive
+ description:
+ name: crypto
+ sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.7"
+ dbus:
+ dependency: transitive
+ description:
+ name: dbus
+ sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.12"
+ fake_async:
+ dependency: transitive
+ description:
+ name: fake_async
+ sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.3"
+ ffi:
+ dependency: transitive
+ description:
+ name: ffi
+ sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ file:
+ dependency: transitive
+ description:
+ name: file
+ sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.1"
+ fixnum:
+ dependency: transitive
+ description:
+ name: fixnum
+ sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ flutter:
+ dependency: "direct main"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_driver:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_lints:
+ dependency: "direct dev"
+ description:
+ name: flutter_lints
+ sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.0.0"
+ flutter_plugin_android_lifecycle:
+ dependency: transitive
+ description:
+ name: flutter_plugin_android_lifecycle
+ sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.33"
+ flutter_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ flutter_web_plugins:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ fuchsia_remote_debug_protocol:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ http:
+ dependency: transitive
+ description:
+ name: http
+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.6.0"
+ http_parser:
+ dependency: transitive
+ description:
+ name: http_parser
+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.1.2"
+ image:
+ dependency: transitive
+ description:
+ name: image
+ sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.8.0"
+ integration_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.0.2"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.10"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ lints:
+ dependency: transitive
+ description:
+ name: lints
+ sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.1.0"
+ matcher:
+ dependency: transitive
+ description:
+ name: matcher
+ sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.12.19"
+ material_color_utilities:
+ dependency: transitive
+ description:
+ name: material_color_utilities
+ sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.13.0"
+ media_kit:
+ dependency: "direct main"
+ description:
+ name: media_kit
+ sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.6"
+ media_kit_libs_linux:
+ dependency: "direct main"
+ description:
+ name: media_kit_libs_linux
+ sha256: "2b473399a49ec94452c4d4ae51cfc0f6585074398d74216092bf3d54aac37ecf"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.1"
+ media_kit_libs_macos_video:
+ dependency: "direct main"
+ description:
+ name: media_kit_libs_macos_video
+ sha256: f26aa1452b665df288e360393758f84b911f70ffb3878032e1aabba23aa1032d
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.4"
+ media_kit_libs_windows_video:
+ dependency: "direct main"
+ description:
+ name: media_kit_libs_windows_video
+ sha256: dff76da2778729ab650229e6b4ec6ec111eb5151431002cbd7ea304ff1f112ab
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.0.11"
+ media_kit_video:
+ dependency: "direct main"
+ description:
+ name: media_kit_video
+ sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ meta:
+ dependency: transitive
+ description:
+ name: meta
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.18.0"
+ package_info_plus:
+ dependency: transitive
+ description:
+ name: package_info_plus
+ sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.0.0"
+ package_info_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: package_info_plus_platform_interface
+ sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.2.1"
+ path:
+ dependency: transitive
+ description:
+ name: path
+ sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.9.1"
+ petitparser:
+ dependency: transitive
+ description:
+ name: petitparser
+ sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+ url: "https://pub.dev"
+ source: hosted
+ version: "7.0.2"
+ platform:
+ dependency: transitive
+ description:
+ name: platform
+ sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.6"
+ plugin_platform_interface:
+ dependency: transitive
+ description:
+ name: plugin_platform_interface
+ sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.8"
+ posix:
+ dependency: transitive
+ description:
+ name: posix
+ sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.5.0"
+ process:
+ dependency: transitive
+ description:
+ name: process
+ sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.5"
+ safe_local_storage:
+ dependency: transitive
+ description:
+ name: safe_local_storage
+ sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.3"
+ sky_engine:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
+ source_span:
+ dependency: transitive
+ description:
+ name: source_span
+ sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.10.2"
+ stack_trace:
+ dependency: transitive
+ description:
+ name: stack_trace
+ sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.12.1"
+ stream_channel:
+ dependency: transitive
+ description:
+ name: stream_channel
+ sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.4"
+ stream_transform:
+ dependency: transitive
+ description:
+ name: stream_transform
+ sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.1"
+ string_scanner:
+ dependency: transitive
+ description:
+ name: string_scanner
+ sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.1"
+ sync_http:
+ dependency: transitive
+ description:
+ name: sync_http
+ sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.1"
+ synchronized:
+ dependency: transitive
+ description:
+ name: synchronized
+ sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.4.0"
+ term_glyph:
+ dependency: transitive
+ description:
+ name: term_glyph
+ sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ test_api:
+ dependency: transitive
+ description:
+ name: test_api
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.7.11"
+ typed_data:
+ dependency: transitive
+ description:
+ name: typed_data
+ sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ universal_platform:
+ dependency: transitive
+ description:
+ name: universal_platform
+ sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.0"
+ uri_parser:
+ dependency: transitive
+ description:
+ name: uri_parser
+ sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.0.2"
+ uuid:
+ dependency: transitive
+ description:
+ name: uuid
+ sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.5.3"
+ vector_math:
+ dependency: transitive
+ description:
+ name: vector_math
+ sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
+ vm_service:
+ dependency: transitive
+ description:
+ name: vm_service
+ sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
+ url: "https://pub.dev"
+ source: hosted
+ version: "15.0.2"
+ wakelock_plus:
+ dependency: transitive
+ description:
+ name: wakelock_plus
+ sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.4.0"
+ wakelock_plus_platform_interface:
+ dependency: transitive
+ description:
+ name: wakelock_plus_platform_interface
+ sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.3.0"
+ web:
+ dependency: transitive
+ description:
+ name: web
+ sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.1.1"
+ webdriver:
+ dependency: transitive
+ description:
+ name: webdriver
+ sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.0"
+ win32:
+ dependency: transitive
+ description:
+ name: win32
+ sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.15.0"
+ xml:
+ dependency: transitive
+ description:
+ name: xml
+ sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+ url: "https://pub.dev"
+ source: hosted
+ version: "6.6.1"
+sdks:
+ dart: ">=3.11.0 <4.0.0"
+ flutter: ">=3.35.0"
diff --git a/plugins/camera_desktop/example/pubspec.yaml b/plugins/camera_desktop/example/pubspec.yaml
new file mode 100644
index 0000000..7db908a
--- /dev/null
+++ b/plugins/camera_desktop/example/pubspec.yaml
@@ -0,0 +1,29 @@
+name: camera_desktop_example
+description: Demonstrates how to use the camera_desktop plugin.
+publish_to: 'none'
+
+environment:
+ sdk: ^3.11.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+ camera: ^0.12.0
+ camera_desktop:
+ path: ../
+ camera_platform_interface: ^2.8.0
+ media_kit: ^1.2.6
+ media_kit_video: ^2.0.1
+ media_kit_libs_windows_video: ^1.0.9
+ media_kit_libs_macos_video: ^1.1.4
+ media_kit_libs_linux: ^1.2.1
+
+dev_dependencies:
+ integration_test:
+ sdk: flutter
+ flutter_test:
+ sdk: flutter
+ flutter_lints: ^6.0.0
+
+flutter:
+ uses-material-design: true
diff --git a/plugins/camera_desktop/example/test/widget_test.dart b/plugins/camera_desktop/example/test/widget_test.dart
new file mode 100644
index 0000000..c7a83f7
--- /dev/null
+++ b/plugins/camera_desktop/example/test/widget_test.dart
@@ -0,0 +1,9 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:camera_desktop_example/main.dart';
+
+void main() {
+ testWidgets('CameraExampleApp builds', (WidgetTester tester) async {
+ await tester.pumpWidget(const CameraExampleApp());
+ expect(find.text('Camera Desktop Example'), findsOneWidget);
+ });
+}
diff --git a/plugins/camera_desktop/example/windows/.gitignore b/plugins/camera_desktop/example/windows/.gitignore
new file mode 100644
index 0000000..d492d0d
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/.gitignore
@@ -0,0 +1,17 @@
+flutter/ephemeral/
+
+# Visual Studio user-specific files.
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Visual Studio build-related files.
+x64/
+x86/
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!*.[Cc]ache/
diff --git a/plugins/camera_desktop/example/windows/CMakeLists.txt b/plugins/camera_desktop/example/windows/CMakeLists.txt
new file mode 100644
index 0000000..d49bfac
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/CMakeLists.txt
@@ -0,0 +1,109 @@
+# Project-level configuration.
+cmake_minimum_required(VERSION 3.14)
+project(camera_desktop_example LANGUAGES CXX)
+
+# The name of the executable created for the application. Change this to change
+# the on-disk name of your application.
+set(BINARY_NAME "camera_desktop_example")
+
+# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
+# versions of CMake.
+cmake_policy(VERSION 3.14...3.25)
+
+# Define build configuration option.
+get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG)
+if(IS_MULTICONFIG)
+ set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release"
+ CACHE STRING "" FORCE)
+else()
+ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
+ set(CMAKE_BUILD_TYPE "Debug" CACHE
+ STRING "Flutter build mode" FORCE)
+ set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
+ "Debug" "Profile" "Release")
+ endif()
+endif()
+# Define settings for the Profile build mode.
+set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}")
+set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}")
+set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}")
+set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
+
+# Use Unicode for all projects.
+add_definitions(-DUNICODE -D_UNICODE)
+
+# Compilation settings that should be applied to most targets.
+#
+# Be cautious about adding new options here, as plugins use this function by
+# default. In most cases, you should add new options to specific targets instead
+# of modifying this function.
+function(APPLY_STANDARD_SETTINGS TARGET)
+ target_compile_features(${TARGET} PUBLIC cxx_std_17)
+ target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100")
+ target_compile_options(${TARGET} PRIVATE /EHsc)
+ target_compile_options(${TARGET} PRIVATE /utf-8)
+ target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0")
+ target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>")
+endfunction()
+
+# Flutter library and tool build rules.
+set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
+add_subdirectory(${FLUTTER_MANAGED_DIR})
+
+# Application build; see runner/CMakeLists.txt.
+add_subdirectory("runner")
+
+
+# Generated plugin build rules, which manage building the plugins and adding
+# them to the application.
+include(flutter/generated_plugins.cmake)
+
+
+# === Installation ===
+# Support files are copied into place next to the executable, so that it can
+# run in place. This is done instead of making a separate bundle (as on Linux)
+# so that building and running from within Visual Studio will work.
+set(BUILD_BUNDLE_DIR "$")
+# Make the "install" step default, as it's required to run.
+set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1)
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
+endif()
+
+set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
+set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}")
+
+install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ COMPONENT Runtime)
+
+install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+if(PLUGIN_BUNDLED_LIBRARIES)
+ install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+endif()
+
+# Copy the native assets provided by the build.dart from all packages.
+set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/")
+install(DIRECTORY "${NATIVE_ASSETS_DIR}"
+ DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
+ COMPONENT Runtime)
+
+# Fully re-copy the assets directory on each build to avoid having stale files
+# from a previous install.
+set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
+install(CODE "
+ file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
+ " COMPONENT Runtime)
+install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
+ DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
+
+# Install the AOT library on non-Debug builds only.
+install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
+ CONFIGURATIONS Profile;Release
+ COMPONENT Runtime)
diff --git a/plugins/camera_desktop/example/windows/flutter/CMakeLists.txt b/plugins/camera_desktop/example/windows/flutter/CMakeLists.txt
new file mode 100644
index 0000000..903f489
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/flutter/CMakeLists.txt
@@ -0,0 +1,109 @@
+# This file controls Flutter-level build steps. It should not be edited.
+cmake_minimum_required(VERSION 3.14)
+
+set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
+
+# Configuration provided via flutter tool.
+include(${EPHEMERAL_DIR}/generated_config.cmake)
+
+# TODO: Move the rest of this into files in ephemeral. See
+# https://github.com/flutter/flutter/issues/57146.
+set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper")
+
+# Set fallback configurations for older versions of the flutter tool.
+if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
+ set(FLUTTER_TARGET_PLATFORM "windows-x64")
+endif()
+
+# === Flutter Library ===
+set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll")
+
+# Published to parent scope for install step.
+set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
+set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
+set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
+set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE)
+
+list(APPEND FLUTTER_LIBRARY_HEADERS
+ "flutter_export.h"
+ "flutter_windows.h"
+ "flutter_messenger.h"
+ "flutter_plugin_registrar.h"
+ "flutter_texture_registrar.h"
+)
+list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/")
+add_library(flutter INTERFACE)
+target_include_directories(flutter INTERFACE
+ "${EPHEMERAL_DIR}"
+)
+target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib")
+add_dependencies(flutter flutter_assemble)
+
+# === Wrapper ===
+list(APPEND CPP_WRAPPER_SOURCES_CORE
+ "core_implementations.cc"
+ "standard_codec.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_PLUGIN
+ "plugin_registrar.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/")
+list(APPEND CPP_WRAPPER_SOURCES_APP
+ "flutter_engine.cc"
+ "flutter_view_controller.cc"
+)
+list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/")
+
+# Wrapper sources needed for a plugin.
+add_library(flutter_wrapper_plugin STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+)
+apply_standard_settings(flutter_wrapper_plugin)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ POSITION_INDEPENDENT_CODE ON)
+set_target_properties(flutter_wrapper_plugin PROPERTIES
+ CXX_VISIBILITY_PRESET hidden)
+target_link_libraries(flutter_wrapper_plugin PUBLIC flutter)
+target_include_directories(flutter_wrapper_plugin PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_plugin flutter_assemble)
+
+# Wrapper sources needed for the runner.
+add_library(flutter_wrapper_app STATIC
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
+apply_standard_settings(flutter_wrapper_app)
+target_link_libraries(flutter_wrapper_app PUBLIC flutter)
+target_include_directories(flutter_wrapper_app PUBLIC
+ "${WRAPPER_ROOT}/include"
+)
+add_dependencies(flutter_wrapper_app flutter_assemble)
+
+# === Flutter tool backend ===
+# _phony_ is a non-existent file to force this command to run every time,
+# since currently there's no way to get a full input/output list from the
+# flutter tool.
+set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_")
+set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE)
+add_custom_command(
+ OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+ ${PHONY_OUTPUT}
+ COMMAND ${CMAKE_COMMAND} -E env
+ ${FLUTTER_TOOL_ENVIRONMENT}
+ "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat"
+ ${FLUTTER_TARGET_PLATFORM} $
+ VERBATIM
+)
+add_custom_target(flutter_assemble DEPENDS
+ "${FLUTTER_LIBRARY}"
+ ${FLUTTER_LIBRARY_HEADERS}
+ ${CPP_WRAPPER_SOURCES_CORE}
+ ${CPP_WRAPPER_SOURCES_PLUGIN}
+ ${CPP_WRAPPER_SOURCES_APP}
+)
diff --git a/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.cc b/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.cc
new file mode 100644
index 0000000..f9fc08d
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.cc
@@ -0,0 +1,20 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#include "generated_plugin_registrant.h"
+
+#include
+#include
+#include
+
+void RegisterPlugins(flutter::PluginRegistry* registry) {
+ CameraDesktopPluginRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("CameraDesktopPlugin"));
+ MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
+ MediaKitVideoPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
+}
diff --git a/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.h b/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.h
new file mode 100644
index 0000000..dc139d8
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/flutter/generated_plugin_registrant.h
@@ -0,0 +1,15 @@
+//
+// Generated file. Do not edit.
+//
+
+// clang-format off
+
+#ifndef GENERATED_PLUGIN_REGISTRANT_
+#define GENERATED_PLUGIN_REGISTRANT_
+
+#include
+
+// Registers Flutter plugins.
+void RegisterPlugins(flutter::PluginRegistry* registry);
+
+#endif // GENERATED_PLUGIN_REGISTRANT_
diff --git a/plugins/camera_desktop/example/windows/flutter/generated_plugins.cmake b/plugins/camera_desktop/example/windows/flutter/generated_plugins.cmake
new file mode 100644
index 0000000..464e078
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/flutter/generated_plugins.cmake
@@ -0,0 +1,26 @@
+#
+# Generated file, do not edit.
+#
+
+list(APPEND FLUTTER_PLUGIN_LIST
+ camera_desktop
+ media_kit_libs_windows_video
+ media_kit_video
+)
+
+list(APPEND FLUTTER_FFI_PLUGIN_LIST
+)
+
+set(PLUGIN_BUNDLED_LIBRARIES)
+
+foreach(plugin ${FLUTTER_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
+ target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES $)
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
+endforeach(plugin)
+
+foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
+ add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
+ list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
+endforeach(ffi_plugin)
diff --git a/plugins/camera_desktop/example/windows/runner/CMakeLists.txt b/plugins/camera_desktop/example/windows/runner/CMakeLists.txt
new file mode 100644
index 0000000..394917c
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/CMakeLists.txt
@@ -0,0 +1,40 @@
+cmake_minimum_required(VERSION 3.14)
+project(runner LANGUAGES CXX)
+
+# Define the application target. To change its name, change BINARY_NAME in the
+# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
+# work.
+#
+# Any new source files that you add to the application should be added here.
+add_executable(${BINARY_NAME} WIN32
+ "flutter_window.cpp"
+ "main.cpp"
+ "utils.cpp"
+ "win32_window.cpp"
+ "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
+ "Runner.rc"
+ "runner.exe.manifest"
+)
+
+# Apply the standard set of build settings. This can be removed for applications
+# that need different build settings.
+apply_standard_settings(${BINARY_NAME})
+
+# Add preprocessor definitions for the build version.
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}")
+target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}")
+
+# Disable Windows macros that collide with C++ standard library functions.
+target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX")
+
+# Add dependency libraries and include directories. Add any application-specific
+# dependencies here.
+target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app)
+target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib")
+target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+
+# Run the Flutter tool portions of the build. This must not be removed.
+add_dependencies(${BINARY_NAME} flutter_assemble)
diff --git a/plugins/camera_desktop/example/windows/runner/Runner.rc b/plugins/camera_desktop/example/windows/runner/Runner.rc
new file mode 100644
index 0000000..3a61f09
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/Runner.rc
@@ -0,0 +1,121 @@
+// Microsoft Visual C++ generated resource script.
+//
+#pragma code_page(65001)
+#include "resource.h"
+
+#define APSTUDIO_READONLY_SYMBOLS
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 2 resource.
+//
+#include "winres.h"
+
+/////////////////////////////////////////////////////////////////////////////
+#undef APSTUDIO_READONLY_SYMBOLS
+
+/////////////////////////////////////////////////////////////////////////////
+// English (United States) resources
+
+#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
+LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
+
+#ifdef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// TEXTINCLUDE
+//
+
+1 TEXTINCLUDE
+BEGIN
+ "resource.h\0"
+END
+
+2 TEXTINCLUDE
+BEGIN
+ "#include ""winres.h""\r\n"
+ "\0"
+END
+
+3 TEXTINCLUDE
+BEGIN
+ "\r\n"
+ "\0"
+END
+
+#endif // APSTUDIO_INVOKED
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Icon
+//
+
+// Icon with lowest ID value placed first to ensure application icon
+// remains consistent on all systems.
+IDI_APP_ICON ICON "resources\\app_icon.ico"
+
+
+/////////////////////////////////////////////////////////////////////////////
+//
+// Version
+//
+
+#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD)
+#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
+#else
+#define VERSION_AS_NUMBER 1,0,0,0
+#endif
+
+#if defined(FLUTTER_VERSION)
+#define VERSION_AS_STRING FLUTTER_VERSION
+#else
+#define VERSION_AS_STRING "1.0.0"
+#endif
+
+VS_VERSION_INFO VERSIONINFO
+ FILEVERSION VERSION_AS_NUMBER
+ PRODUCTVERSION VERSION_AS_NUMBER
+ FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
+#ifdef _DEBUG
+ FILEFLAGS VS_FF_DEBUG
+#else
+ FILEFLAGS 0x0L
+#endif
+ FILEOS VOS__WINDOWS32
+ FILETYPE VFT_APP
+ FILESUBTYPE 0x0L
+BEGIN
+ BLOCK "StringFileInfo"
+ BEGIN
+ BLOCK "040904e4"
+ BEGIN
+ VALUE "CompanyName", "com.example" "\0"
+ VALUE "FileDescription", "camera_desktop_example" "\0"
+ VALUE "FileVersion", VERSION_AS_STRING "\0"
+ VALUE "InternalName", "camera_desktop_example" "\0"
+ VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
+ VALUE "OriginalFilename", "camera_desktop_example.exe" "\0"
+ VALUE "ProductName", "camera_desktop_example" "\0"
+ VALUE "ProductVersion", VERSION_AS_STRING "\0"
+ END
+ END
+ BLOCK "VarFileInfo"
+ BEGIN
+ VALUE "Translation", 0x409, 1252
+ END
+END
+
+#endif // English (United States) resources
+/////////////////////////////////////////////////////////////////////////////
+
+
+
+#ifndef APSTUDIO_INVOKED
+/////////////////////////////////////////////////////////////////////////////
+//
+// Generated from the TEXTINCLUDE 3 resource.
+//
+
+
+/////////////////////////////////////////////////////////////////////////////
+#endif // not APSTUDIO_INVOKED
diff --git a/plugins/camera_desktop/example/windows/runner/flutter_window.cpp b/plugins/camera_desktop/example/windows/runner/flutter_window.cpp
new file mode 100644
index 0000000..955ee30
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/flutter_window.cpp
@@ -0,0 +1,71 @@
+#include "flutter_window.h"
+
+#include
+
+#include "flutter/generated_plugin_registrant.h"
+
+FlutterWindow::FlutterWindow(const flutter::DartProject& project)
+ : project_(project) {}
+
+FlutterWindow::~FlutterWindow() {}
+
+bool FlutterWindow::OnCreate() {
+ if (!Win32Window::OnCreate()) {
+ return false;
+ }
+
+ RECT frame = GetClientArea();
+
+ // The size here must match the window dimensions to avoid unnecessary surface
+ // creation / destruction in the startup path.
+ flutter_controller_ = std::make_unique(
+ frame.right - frame.left, frame.bottom - frame.top, project_);
+ // Ensure that basic setup of the controller was successful.
+ if (!flutter_controller_->engine() || !flutter_controller_->view()) {
+ return false;
+ }
+ RegisterPlugins(flutter_controller_->engine());
+ SetChildContent(flutter_controller_->view()->GetNativeWindow());
+
+ flutter_controller_->engine()->SetNextFrameCallback([&]() {
+ this->Show();
+ });
+
+ // Flutter can complete the first frame before the "show window" callback is
+ // registered. The following call ensures a frame is pending to ensure the
+ // window is shown. It is a no-op if the first frame hasn't completed yet.
+ flutter_controller_->ForceRedraw();
+
+ return true;
+}
+
+void FlutterWindow::OnDestroy() {
+ if (flutter_controller_) {
+ flutter_controller_ = nullptr;
+ }
+
+ Win32Window::OnDestroy();
+}
+
+LRESULT
+FlutterWindow::MessageHandler(HWND hwnd, UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ // Give Flutter, including plugins, an opportunity to handle window messages.
+ if (flutter_controller_) {
+ std::optional result =
+ flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
+ lparam);
+ if (result) {
+ return *result;
+ }
+ }
+
+ switch (message) {
+ case WM_FONTCHANGE:
+ flutter_controller_->engine()->ReloadSystemFonts();
+ break;
+ }
+
+ return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
+}
diff --git a/plugins/camera_desktop/example/windows/runner/flutter_window.h b/plugins/camera_desktop/example/windows/runner/flutter_window.h
new file mode 100644
index 0000000..6da0652
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/flutter_window.h
@@ -0,0 +1,33 @@
+#ifndef RUNNER_FLUTTER_WINDOW_H_
+#define RUNNER_FLUTTER_WINDOW_H_
+
+#include
+#include
+
+#include
+
+#include "win32_window.h"
+
+// A window that does nothing but host a Flutter view.
+class FlutterWindow : public Win32Window {
+ public:
+ // Creates a new FlutterWindow hosting a Flutter view running |project|.
+ explicit FlutterWindow(const flutter::DartProject& project);
+ virtual ~FlutterWindow();
+
+ protected:
+ // Win32Window:
+ bool OnCreate() override;
+ void OnDestroy() override;
+ LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam,
+ LPARAM const lparam) noexcept override;
+
+ private:
+ // The project to run.
+ flutter::DartProject project_;
+
+ // The Flutter instance hosted by this window.
+ std::unique_ptr flutter_controller_;
+};
+
+#endif // RUNNER_FLUTTER_WINDOW_H_
diff --git a/plugins/camera_desktop/example/windows/runner/main.cpp b/plugins/camera_desktop/example/windows/runner/main.cpp
new file mode 100644
index 0000000..2c4544b
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/main.cpp
@@ -0,0 +1,43 @@
+#include
+#include
+#include
+
+#include "flutter_window.h"
+#include "utils.h"
+
+int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
+ _In_ wchar_t *command_line, _In_ int show_command) {
+ // Attach to console when present (e.g., 'flutter run') or create a
+ // new console when running with a debugger.
+ if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
+ CreateAndAttachConsole();
+ }
+
+ // Initialize COM, so that it is available for use in the library and/or
+ // plugins.
+ ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
+
+ flutter::DartProject project(L"data");
+
+ std::vector command_line_arguments =
+ GetCommandLineArguments();
+
+ project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
+
+ FlutterWindow window(project);
+ Win32Window::Point origin(10, 10);
+ Win32Window::Size size(1280, 720);
+ if (!window.Create(L"camera_desktop_example", origin, size)) {
+ return EXIT_FAILURE;
+ }
+ window.SetQuitOnClose(true);
+
+ ::MSG msg;
+ while (::GetMessage(&msg, nullptr, 0, 0)) {
+ ::TranslateMessage(&msg);
+ ::DispatchMessage(&msg);
+ }
+
+ ::CoUninitialize();
+ return EXIT_SUCCESS;
+}
diff --git a/plugins/camera_desktop/example/windows/runner/resource.h b/plugins/camera_desktop/example/windows/runner/resource.h
new file mode 100644
index 0000000..66a65d1
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/resource.h
@@ -0,0 +1,16 @@
+//{{NO_DEPENDENCIES}}
+// Microsoft Visual C++ generated include file.
+// Used by Runner.rc
+//
+#define IDI_APP_ICON 101
+
+// Next default values for new objects
+//
+#ifdef APSTUDIO_INVOKED
+#ifndef APSTUDIO_READONLY_SYMBOLS
+#define _APS_NEXT_RESOURCE_VALUE 102
+#define _APS_NEXT_COMMAND_VALUE 40001
+#define _APS_NEXT_CONTROL_VALUE 1001
+#define _APS_NEXT_SYMED_VALUE 101
+#endif
+#endif
diff --git a/plugins/camera_desktop/example/windows/runner/resources/app_icon.ico b/plugins/camera_desktop/example/windows/runner/resources/app_icon.ico
new file mode 100644
index 0000000..c04e20c
Binary files /dev/null and b/plugins/camera_desktop/example/windows/runner/resources/app_icon.ico differ
diff --git a/plugins/camera_desktop/example/windows/runner/runner.exe.manifest b/plugins/camera_desktop/example/windows/runner/runner.exe.manifest
new file mode 100644
index 0000000..153653e
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/runner.exe.manifest
@@ -0,0 +1,14 @@
+
+
+
+
+ PerMonitorV2
+
+
+
+
+
+
+
+
+
diff --git a/plugins/camera_desktop/example/windows/runner/utils.cpp b/plugins/camera_desktop/example/windows/runner/utils.cpp
new file mode 100644
index 0000000..3a0b465
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/utils.cpp
@@ -0,0 +1,65 @@
+#include "utils.h"
+
+#include
+#include
+#include
+#include
+
+#include
+
+void CreateAndAttachConsole() {
+ if (::AllocConsole()) {
+ FILE *unused;
+ if (freopen_s(&unused, "CONOUT$", "w", stdout)) {
+ _dup2(_fileno(stdout), 1);
+ }
+ if (freopen_s(&unused, "CONOUT$", "w", stderr)) {
+ _dup2(_fileno(stdout), 2);
+ }
+ std::ios::sync_with_stdio();
+ FlutterDesktopResyncOutputStreams();
+ }
+}
+
+std::vector GetCommandLineArguments() {
+ // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use.
+ int argc;
+ wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc);
+ if (argv == nullptr) {
+ return std::vector();
+ }
+
+ std::vector command_line_arguments;
+
+ // Skip the first argument as it's the binary name.
+ for (int i = 1; i < argc; i++) {
+ command_line_arguments.push_back(Utf8FromUtf16(argv[i]));
+ }
+
+ ::LocalFree(argv);
+
+ return command_line_arguments;
+}
+
+std::string Utf8FromUtf16(const wchar_t* utf16_string) {
+ if (utf16_string == nullptr) {
+ return std::string();
+ }
+ unsigned int target_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ -1, nullptr, 0, nullptr, nullptr)
+ -1; // remove the trailing null character
+ int input_length = (int)wcslen(utf16_string);
+ std::string utf8_string;
+ if (target_length == 0 || target_length > utf8_string.max_size()) {
+ return utf8_string;
+ }
+ utf8_string.resize(target_length);
+ int converted_length = ::WideCharToMultiByte(
+ CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string,
+ input_length, utf8_string.data(), target_length, nullptr, nullptr);
+ if (converted_length == 0) {
+ return std::string();
+ }
+ return utf8_string;
+}
diff --git a/plugins/camera_desktop/example/windows/runner/utils.h b/plugins/camera_desktop/example/windows/runner/utils.h
new file mode 100644
index 0000000..3879d54
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/utils.h
@@ -0,0 +1,19 @@
+#ifndef RUNNER_UTILS_H_
+#define RUNNER_UTILS_H_
+
+#include
+#include
+
+// Creates a console for the process, and redirects stdout and stderr to
+// it for both the runner and the Flutter library.
+void CreateAndAttachConsole();
+
+// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string
+// encoded in UTF-8. Returns an empty std::string on failure.
+std::string Utf8FromUtf16(const wchar_t* utf16_string);
+
+// Gets the command line arguments passed in as a std::vector,
+// encoded in UTF-8. Returns an empty std::vector on failure.
+std::vector GetCommandLineArguments();
+
+#endif // RUNNER_UTILS_H_
diff --git a/plugins/camera_desktop/example/windows/runner/win32_window.cpp b/plugins/camera_desktop/example/windows/runner/win32_window.cpp
new file mode 100644
index 0000000..60608d0
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/win32_window.cpp
@@ -0,0 +1,288 @@
+#include "win32_window.h"
+
+#include
+#include
+
+#include "resource.h"
+
+namespace {
+
+/// Window attribute that enables dark mode window decorations.
+///
+/// Redefined in case the developer's machine has a Windows SDK older than
+/// version 10.0.22000.0.
+/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
+#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
+#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
+#endif
+
+constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
+
+/// Registry key for app theme preference.
+///
+/// A value of 0 indicates apps should use dark mode. A non-zero or missing
+/// value indicates apps should use light mode.
+constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
+ L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
+constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme";
+
+// The number of Win32Window objects that currently exist.
+static int g_active_window_count = 0;
+
+using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
+
+// Scale helper to convert logical scaler values to physical using passed in
+// scale factor
+int Scale(int source, double scale_factor) {
+ return static_cast(source * scale_factor);
+}
+
+// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
+// This API is only needed for PerMonitor V1 awareness mode.
+void EnableFullDpiSupportIfAvailable(HWND hwnd) {
+ HMODULE user32_module = LoadLibraryA("User32.dll");
+ if (!user32_module) {
+ return;
+ }
+ auto enable_non_client_dpi_scaling =
+ reinterpret_cast(
+ GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
+ if (enable_non_client_dpi_scaling != nullptr) {
+ enable_non_client_dpi_scaling(hwnd);
+ }
+ FreeLibrary(user32_module);
+}
+
+} // namespace
+
+// Manages the Win32Window's window class registration.
+class WindowClassRegistrar {
+ public:
+ ~WindowClassRegistrar() = default;
+
+ // Returns the singleton registrar instance.
+ static WindowClassRegistrar* GetInstance() {
+ if (!instance_) {
+ instance_ = new WindowClassRegistrar();
+ }
+ return instance_;
+ }
+
+ // Returns the name of the window class, registering the class if it hasn't
+ // previously been registered.
+ const wchar_t* GetWindowClass();
+
+ // Unregisters the window class. Should only be called if there are no
+ // instances of the window.
+ void UnregisterWindowClass();
+
+ private:
+ WindowClassRegistrar() = default;
+
+ static WindowClassRegistrar* instance_;
+
+ bool class_registered_ = false;
+};
+
+WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
+
+const wchar_t* WindowClassRegistrar::GetWindowClass() {
+ if (!class_registered_) {
+ WNDCLASS window_class{};
+ window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
+ window_class.lpszClassName = kWindowClassName;
+ window_class.style = CS_HREDRAW | CS_VREDRAW;
+ window_class.cbClsExtra = 0;
+ window_class.cbWndExtra = 0;
+ window_class.hInstance = GetModuleHandle(nullptr);
+ window_class.hIcon =
+ LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON));
+ window_class.hbrBackground = 0;
+ window_class.lpszMenuName = nullptr;
+ window_class.lpfnWndProc = Win32Window::WndProc;
+ RegisterClass(&window_class);
+ class_registered_ = true;
+ }
+ return kWindowClassName;
+}
+
+void WindowClassRegistrar::UnregisterWindowClass() {
+ UnregisterClass(kWindowClassName, nullptr);
+ class_registered_ = false;
+}
+
+Win32Window::Win32Window() {
+ ++g_active_window_count;
+}
+
+Win32Window::~Win32Window() {
+ --g_active_window_count;
+ Destroy();
+}
+
+bool Win32Window::Create(const std::wstring& title,
+ const Point& origin,
+ const Size& size) {
+ Destroy();
+
+ const wchar_t* window_class =
+ WindowClassRegistrar::GetInstance()->GetWindowClass();
+
+ const POINT target_point = {static_cast(origin.x),
+ static_cast(origin.y)};
+ HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
+ UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
+ double scale_factor = dpi / 96.0;
+
+ HWND window = CreateWindow(
+ window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
+ Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
+ Scale(size.width, scale_factor), Scale(size.height, scale_factor),
+ nullptr, nullptr, GetModuleHandle(nullptr), this);
+
+ if (!window) {
+ return false;
+ }
+
+ UpdateTheme(window);
+
+ return OnCreate();
+}
+
+bool Win32Window::Show() {
+ return ShowWindow(window_handle_, SW_SHOWNORMAL);
+}
+
+// static
+LRESULT CALLBACK Win32Window::WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ if (message == WM_NCCREATE) {
+ auto window_struct = reinterpret_cast(lparam);
+ SetWindowLongPtr(window, GWLP_USERDATA,
+ reinterpret_cast(window_struct->lpCreateParams));
+
+ auto that = static_cast(window_struct->lpCreateParams);
+ EnableFullDpiSupportIfAvailable(window);
+ that->window_handle_ = window;
+ } else if (Win32Window* that = GetThisFromHandle(window)) {
+ return that->MessageHandler(window, message, wparam, lparam);
+ }
+
+ return DefWindowProc(window, message, wparam, lparam);
+}
+
+LRESULT
+Win32Window::MessageHandler(HWND hwnd,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept {
+ switch (message) {
+ case WM_DESTROY:
+ window_handle_ = nullptr;
+ Destroy();
+ if (quit_on_close_) {
+ PostQuitMessage(0);
+ }
+ return 0;
+
+ case WM_DPICHANGED: {
+ auto newRectSize = reinterpret_cast(lparam);
+ LONG newWidth = newRectSize->right - newRectSize->left;
+ LONG newHeight = newRectSize->bottom - newRectSize->top;
+
+ SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
+ newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
+
+ return 0;
+ }
+ case WM_SIZE: {
+ RECT rect = GetClientArea();
+ if (child_content_ != nullptr) {
+ // Size and position the child window.
+ MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
+ rect.bottom - rect.top, TRUE);
+ }
+ return 0;
+ }
+
+ case WM_ACTIVATE:
+ if (child_content_ != nullptr) {
+ SetFocus(child_content_);
+ }
+ return 0;
+
+ case WM_DWMCOLORIZATIONCOLORCHANGED:
+ UpdateTheme(hwnd);
+ return 0;
+ }
+
+ return DefWindowProc(window_handle_, message, wparam, lparam);
+}
+
+void Win32Window::Destroy() {
+ OnDestroy();
+
+ if (window_handle_) {
+ DestroyWindow(window_handle_);
+ window_handle_ = nullptr;
+ }
+ if (g_active_window_count == 0) {
+ WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
+ }
+}
+
+Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
+ return reinterpret_cast(
+ GetWindowLongPtr(window, GWLP_USERDATA));
+}
+
+void Win32Window::SetChildContent(HWND content) {
+ child_content_ = content;
+ SetParent(content, window_handle_);
+ RECT frame = GetClientArea();
+
+ MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
+ frame.bottom - frame.top, true);
+
+ SetFocus(child_content_);
+}
+
+RECT Win32Window::GetClientArea() {
+ RECT frame;
+ GetClientRect(window_handle_, &frame);
+ return frame;
+}
+
+HWND Win32Window::GetHandle() {
+ return window_handle_;
+}
+
+void Win32Window::SetQuitOnClose(bool quit_on_close) {
+ quit_on_close_ = quit_on_close;
+}
+
+bool Win32Window::OnCreate() {
+ // No-op; provided for subclasses.
+ return true;
+}
+
+void Win32Window::OnDestroy() {
+ // No-op; provided for subclasses.
+}
+
+void Win32Window::UpdateTheme(HWND const window) {
+ DWORD light_mode;
+ DWORD light_mode_size = sizeof(light_mode);
+ LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
+ kGetPreferredBrightnessRegValue,
+ RRF_RT_REG_DWORD, nullptr, &light_mode,
+ &light_mode_size);
+
+ if (result == ERROR_SUCCESS) {
+ BOOL enable_dark_mode = light_mode == 0;
+ DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
+ &enable_dark_mode, sizeof(enable_dark_mode));
+ }
+}
diff --git a/plugins/camera_desktop/example/windows/runner/win32_window.h b/plugins/camera_desktop/example/windows/runner/win32_window.h
new file mode 100644
index 0000000..e901dde
--- /dev/null
+++ b/plugins/camera_desktop/example/windows/runner/win32_window.h
@@ -0,0 +1,102 @@
+#ifndef RUNNER_WIN32_WINDOW_H_
+#define RUNNER_WIN32_WINDOW_H_
+
+#include
+
+#include
+#include
+#include
+
+// A class abstraction for a high DPI-aware Win32 Window. Intended to be
+// inherited from by classes that wish to specialize with custom
+// rendering and input handling
+class Win32Window {
+ public:
+ struct Point {
+ unsigned int x;
+ unsigned int y;
+ Point(unsigned int x, unsigned int y) : x(x), y(y) {}
+ };
+
+ struct Size {
+ unsigned int width;
+ unsigned int height;
+ Size(unsigned int width, unsigned int height)
+ : width(width), height(height) {}
+ };
+
+ Win32Window();
+ virtual ~Win32Window();
+
+ // Creates a win32 window with |title| that is positioned and sized using
+ // |origin| and |size|. New windows are created on the default monitor. Window
+ // sizes are specified to the OS in physical pixels, hence to ensure a
+ // consistent size this function will scale the inputted width and height as
+ // as appropriate for the default monitor. The window is invisible until
+ // |Show| is called. Returns true if the window was created successfully.
+ bool Create(const std::wstring& title, const Point& origin, const Size& size);
+
+ // Show the current window. Returns true if the window was successfully shown.
+ bool Show();
+
+ // Release OS resources associated with window.
+ void Destroy();
+
+ // Inserts |content| into the window tree.
+ void SetChildContent(HWND content);
+
+ // Returns the backing Window handle to enable clients to set icon and other
+ // window properties. Returns nullptr if the window has been destroyed.
+ HWND GetHandle();
+
+ // If true, closing this window will quit the application.
+ void SetQuitOnClose(bool quit_on_close);
+
+ // Return a RECT representing the bounds of the current client area.
+ RECT GetClientArea();
+
+ protected:
+ // Processes and route salient window messages for mouse handling,
+ // size change and DPI. Delegates handling of these to member overloads that
+ // inheriting classes can handle.
+ virtual LRESULT MessageHandler(HWND window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Called when CreateAndShow is called, allowing subclass window-related
+ // setup. Subclasses should return false if setup fails.
+ virtual bool OnCreate();
+
+ // Called when Destroy is called.
+ virtual void OnDestroy();
+
+ private:
+ friend class WindowClassRegistrar;
+
+ // OS callback called by message pump. Handles the WM_NCCREATE message which
+ // is passed when the non-client area is being created and enables automatic
+ // non-client DPI scaling so that the non-client area automatically
+ // responds to changes in DPI. All other messages are handled by
+ // MessageHandler.
+ static LRESULT CALLBACK WndProc(HWND const window,
+ UINT const message,
+ WPARAM const wparam,
+ LPARAM const lparam) noexcept;
+
+ // Retrieves a class instance pointer for |window|
+ static Win32Window* GetThisFromHandle(HWND const window) noexcept;
+
+ // Update the window frame's theme to match the system theme.
+ static void UpdateTheme(HWND const window);
+
+ bool quit_on_close_ = false;
+
+ // window handle for top level window.
+ HWND window_handle_ = nullptr;
+
+ // window handle for hosted content.
+ HWND child_content_ = nullptr;
+};
+
+#endif // RUNNER_WIN32_WINDOW_H_
diff --git a/plugins/camera_desktop/ios/camera_desktop.podspec b/plugins/camera_desktop/ios/camera_desktop.podspec
new file mode 100644
index 0000000..3737c1e
--- /dev/null
+++ b/plugins/camera_desktop/ios/camera_desktop.podspec
@@ -0,0 +1,23 @@
+Pod::Spec.new do |s|
+ s.name = 'camera_desktop'
+ s.version = '1.2.1'
+ s.summary = 'Flutter camera plugin (iOS stub).'
+ s.description = <<-DESC
+Flutter camera plugin for desktop platforms. iOS stub for platform declaration.
+ DESC
+ s.homepage = 'https://github.com/hugocornellier/camera_desktop'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Hugo Cornellier' => 'hugo@hugocornellier.com' }
+ s.source = { :path => '.' }
+ s.source_files = 'camera_desktop/Sources/camera_desktop/**/*.{swift,h,m}'
+ s.dependency 'Flutter'
+ s.platform = :ios, '13.0'
+
+ s.resource_bundles = { 'camera_desktop_privacy' => ['camera_desktop/Sources/camera_desktop/PrivacyInfo.xcprivacy'] }
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
+ }
+ s.swift_version = '5.0'
+end
diff --git a/plugins/camera_desktop/ios/camera_desktop/Package.swift b/plugins/camera_desktop/ios/camera_desktop/Package.swift
new file mode 100644
index 0000000..04b09a0
--- /dev/null
+++ b/plugins/camera_desktop/ios/camera_desktop/Package.swift
@@ -0,0 +1,27 @@
+// swift-tools-version: 5.9
+
+import PackageDescription
+
+let package = Package(
+ name: "camera_desktop",
+ platforms: [
+ .iOS("13.0")
+ ],
+ products: [
+ .library(name: "camera-desktop", targets: ["camera_desktop"])
+ ],
+ dependencies: [
+ .package(name: "FlutterFramework", path: "../FlutterFramework")
+ ],
+ targets: [
+ .target(
+ name: "camera_desktop",
+ dependencies: [
+ .product(name: "FlutterFramework", package: "FlutterFramework")
+ ],
+ resources: [
+ .process("PrivacyInfo.xcprivacy"),
+ ]
+ )
+ ]
+)
diff --git a/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/CameraDesktopPlugin.swift b/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/CameraDesktopPlugin.swift
new file mode 100644
index 0000000..e6ad649
--- /dev/null
+++ b/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/CameraDesktopPlugin.swift
@@ -0,0 +1,19 @@
+import Flutter
+import UIKit
+
+public class CameraDesktopPlugin: NSObject, FlutterPlugin {
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(name: "camera_desktop", binaryMessenger: registrar.messenger())
+ let instance = CameraDesktopPlugin()
+ registrar.addMethodCallDelegate(instance, channel: channel)
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "getPlatformVersion":
+ result("iOS " + UIDevice.current.systemVersion)
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+}
diff --git a/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/PrivacyInfo.xcprivacy b/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..a34b7e2
--- /dev/null
+++ b/plugins/camera_desktop/ios/camera_desktop/Sources/camera_desktop/PrivacyInfo.xcprivacy
@@ -0,0 +1,14 @@
+
+
+
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyAccessedAPITypes
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyTracking
+
+
+
diff --git a/plugins/camera_desktop/lib/camera_desktop.dart b/plugins/camera_desktop/lib/camera_desktop.dart
new file mode 100644
index 0000000..96461a2
--- /dev/null
+++ b/plugins/camera_desktop/lib/camera_desktop.dart
@@ -0,0 +1,9 @@
+/// A Flutter camera plugin for desktop platforms (Linux, macOS, Windows).
+///
+/// This plugin implements [CameraPlatform] from camera_platform_interface,
+/// allowing it to work seamlessly with the standard camera package.
+/// Users simply add camera_desktop as a dependency and the standard
+/// CameraController works on desktop automatically.
+library;
+
+export 'src/camera_desktop_plugin.dart';
diff --git a/plugins/camera_desktop/lib/src/camera_desktop_plugin.dart b/plugins/camera_desktop/lib/src/camera_desktop_plugin.dart
new file mode 100644
index 0000000..8131583
--- /dev/null
+++ b/plugins/camera_desktop/lib/src/camera_desktop_plugin.dart
@@ -0,0 +1,685 @@
+import 'dart:async';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+import 'package:stream_transform/stream_transform.dart';
+
+import 'image_stream_ffi.dart';
+
+/// Desktop implementation of [CameraPlatform].
+///
+/// On Linux, uses GStreamer + V4L2. On macOS, uses AVFoundation.
+/// On Windows, uses Media Foundation (IMFCaptureEngine).
+///
+/// This plugin registers itself as the camera platform implementation for
+/// desktop. When an app depends on both `camera` and `camera_desktop`, Flutter
+/// automatically calls [registerWith], making [CameraController] work out of
+/// the box.
+class CameraDesktopPlugin extends CameraPlatform {
+ /// Creates a new [CameraDesktopPlugin].
+ ///
+ /// The [channel] parameter is exposed for testing only.
+ CameraDesktopPlugin({
+ @visibleForTesting MethodChannel? channel,
+ this.mirrorPreview = true,
+ }) : _channel =
+ channel ?? const MethodChannel('plugins.flutter.io/camera_desktop');
+
+ /// Registers this class as the default [CameraPlatform] implementation.
+ static void registerWith() {
+ CameraPlatform.instance = CameraDesktopPlugin();
+ }
+
+ /// The method channel used to communicate with the native platform.
+ final MethodChannel _channel;
+
+ /// Returns desktop backend capabilities for feature-gating advanced controls.
+ ///
+ /// Keys are stable capability names (e.g. `supportsMirrorControl`).
+ /// If the native side does not implement this method yet, returns an empty map.
+ Future> getPlatformCapabilities() async {
+ try {
+ final raw = await _channel.invokeMapMethod(
+ 'getPlatformCapabilities',
+ );
+ if (raw == null) return const {};
+ final out = {};
+ raw.forEach((key, value) {
+ if (value is bool) {
+ out[key] = value;
+ }
+ });
+ return out;
+ } on MissingPluginException {
+ return const {};
+ } on PlatformException {
+ return const {};
+ }
+ }
+
+ /// Whether to mirror the preview horizontally (like a mirror).
+ /// Defaults to `true`. Set to `false` to show the unmirrored camera image.
+ @Deprecated('Mirroring is now handled at the native capture level.')
+ final bool mirrorPreview;
+
+ /// Whether the native → Dart method-call handler has been installed.
+ bool _nativeCallHandlerSet = false;
+
+ /// Lazily installs the native → Dart method-call handler.
+ ///
+ /// Called before the first camera is created. This cannot run in the
+ /// constructor because [registerWith] executes during plugin registration,
+ /// before [WidgetsFlutterBinding.ensureInitialized].
+ void _ensureNativeCallHandler() {
+ if (!_nativeCallHandlerSet) {
+ _channel.setMethodCallHandler(_handleNativeCall);
+ _nativeCallHandlerSet = true;
+ }
+ }
+
+ /// Mapping from cameraId to textureId (separate to decouple lifecycles).
+ final Map _textureIds = {};
+
+ /// Broadcast stream for all camera events, filtered by cameraId downstream.
+ final StreamController _eventStreamController =
+ StreamController.broadcast();
+
+ /// Per-camera image stream controllers for [onStreamedFrameAvailable].
+ ///
+ /// Only populated when [ImageStreamFfi] is unavailable and the fallback
+ /// MethodChannel path is used for frame delivery. When FFI is active,
+ /// frames bypass this map entirely and `_handleNativeCall`'s
+ /// `imageStreamFrame` branch is a no-op for that camera.
+ final Map> _imageStreamControllers =
+ {};
+
+ /// Active image streams (FFI or fallback) keyed by cameraId.
+ ///
+ /// Lets [dispose] tear down a stream whose subscription was never cancelled —
+ /// e.g. when an app disposes its `CameraController` without first calling
+ /// `stopImageStream()`. In that case `onCancel` never fires, so without this
+ /// the FFI poll timer (and its controller) would leak and keep polling.
+ final Map _activeImageStreams = {};
+
+ /// Factory for the FFI image-stream poller. Overridable in tests to inject a
+ /// fake (or capture the real) poller without depending on call timing.
+ @visibleForTesting
+ ImageStreamPoller? Function(int streamHandle) imageStreamPollerFactory =
+ ImageStreamFfi.tryCreate;
+
+ /// Handles method calls from the native side (events pushed to Dart).
+ ///
+ /// Dispatches `cameraError`, `cameraClosing`, and `imageStreamFrame`
+ /// events from native code into the appropriate Dart stream controllers.
+ Future _handleNativeCall(MethodCall call) async {
+ final args = call.arguments as Map?;
+ switch (call.method) {
+ case 'cameraError':
+ final cameraId = args!['cameraId']! as int;
+ final description = args['description']! as String;
+ _eventStreamController.add(CameraErrorEvent(cameraId, description));
+ case 'cameraClosing':
+ final cameraId = args!['cameraId']! as int;
+ _eventStreamController.add(CameraClosingEvent(cameraId));
+ case 'imageStreamFrame':
+ final cameraId = args!['cameraId']! as int;
+ final controller = _imageStreamControllers[cameraId];
+ if (controller != null && !controller.isClosed) {
+ final width = args['width']! as int;
+ final height = args['height']! as int;
+ final bytesPerRow = args['bytesPerRow'] as int? ?? (width * 4);
+ final bytes = args['bytes']! as Uint8List;
+ controller.add(
+ CameraImageData(
+ format: CameraImageFormat(
+ ImageFormatGroup.bgra8888,
+ raw: Platform.isMacOS ? 'BGRA' : 'RGBA',
+ ),
+ width: width,
+ height: height,
+ planes: [
+ CameraImagePlane(
+ bytes: bytes,
+ bytesPerRow: bytesPerRow,
+ bytesPerPixel: 4,
+ width: width,
+ height: height,
+ ),
+ ],
+ ),
+ );
+ }
+ }
+ }
+
+ /// Filters the global event stream to events for a specific [cameraId].
+ Stream _cameraEvents(int cameraId) => _eventStreamController
+ .stream
+ .where((CameraEvent e) => e.cameraId == cameraId);
+
+ @override
+ Future> availableCameras() async {
+ final result = await _channel.invokeListMethod>(
+ 'availableCameras',
+ );
+ if (result == null) return [];
+ return result.map((Map m) {
+ return CameraDescription(
+ name: m['name'] as String,
+ lensDirection: CameraLensDirection.values[m['lensDirection'] as int],
+ sensorOrientation: m['sensorOrientation'] as int,
+ );
+ }).toList();
+ }
+
+ @override
+ Future createCamera(
+ CameraDescription cameraDescription,
+ ResolutionPreset? resolutionPreset, {
+ bool enableAudio = false,
+ }) async {
+ return createCameraWithSettings(
+ cameraDescription,
+ MediaSettings(
+ resolutionPreset: resolutionPreset,
+ enableAudio: enableAudio,
+ ),
+ );
+ }
+
+ /// Creates a camera with the given [mediaSettings].
+ ///
+ /// The `videoBitrate` and `audioBitrate` fields are accessed via dynamic
+ /// dispatch with try/catch because older versions of
+ /// `camera_platform_interface` may not expose them.
+ @override
+ Future createCameraWithSettings(
+ CameraDescription cameraDescription,
+ MediaSettings mediaSettings,
+ ) async {
+ _ensureNativeCallHandler();
+ int? videoBitrate;
+ try {
+ final dynamic dynamicSettings = mediaSettings;
+ final dynamic value = dynamicSettings.videoBitrate;
+ if (value is int) {
+ videoBitrate = value;
+ } else if (value is num) {
+ videoBitrate = value.toInt();
+ }
+ } catch (_) {}
+ int? audioBitrate;
+ try {
+ final dynamic dynamicSettings = mediaSettings;
+ final dynamic value = dynamicSettings.audioBitrate;
+ if (value is int) {
+ audioBitrate = value;
+ } else if (value is num) {
+ audioBitrate = value.toInt();
+ }
+ } catch (_) {}
+ try {
+ final result = await _channel.invokeMapMethod('create', {
+ 'cameraName': cameraDescription.name,
+ 'resolutionPreset':
+ mediaSettings.resolutionPreset?.index ?? ResolutionPreset.max.index,
+ 'enableAudio': mediaSettings.enableAudio,
+ 'fps': mediaSettings.fps,
+ if (videoBitrate != null) 'videoBitrate': videoBitrate,
+ if (audioBitrate != null) 'audioBitrate': audioBitrate,
+ });
+ final cameraId = result!['cameraId'] as int;
+ final textureId = result['textureId'] as int;
+ _textureIds[cameraId] = textureId;
+ return cameraId;
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ @override
+ Future initializeCamera(
+ int cameraId, {
+ ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown,
+ }) async {
+ try {
+ final result = await _channel.invokeMapMethod(
+ 'initialize',
+ {'cameraId': cameraId},
+ );
+ _eventStreamController.add(
+ CameraInitializedEvent(
+ cameraId,
+ (result!['previewWidth'] as num).toDouble(),
+ (result['previewHeight'] as num).toDouble(),
+ ExposureMode.auto,
+ false,
+ FocusMode.auto,
+ false,
+ ),
+ );
+ } on PlatformException catch (e) {
+ _eventStreamController.add(
+ CameraErrorEvent(cameraId, e.message ?? 'Initialization failed'),
+ );
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ /// Disposes the camera and releases all associated resources.
+ ///
+ /// Platform exceptions during disposal are silently ignored to ensure
+ /// cleanup always completes.
+ @override
+ Future dispose(int cameraId) async {
+ // Stop any active image-stream poller BEFORE native dispose. This prevents
+ // a leaked 8ms poll timer when the stream subscription was never cancelled
+ // (CameraController.dispose() does not stop image streams), and ensures no
+ // Dart poll reads a shared buffer the native side is about to free.
+ final active = _activeImageStreams.remove(cameraId);
+ if (active != null) {
+ active.tornDown = true;
+ active.ffi?.stop();
+ }
+ try {
+ await _channel.invokeMethod('dispose', {'cameraId': cameraId});
+ } on PlatformException catch (_) {
+ } finally {
+ _textureIds.remove(cameraId);
+ final imageController = _imageStreamControllers.remove(cameraId);
+ if (imageController != null && !imageController.isClosed) {
+ imageController.close();
+ }
+ if (active != null) {
+ active.ffi?.dispose();
+ if (!active.controller.isClosed) {
+ await active.controller.close();
+ }
+ }
+ }
+ }
+
+ @override
+ Stream onCameraInitialized(int cameraId) =>
+ _cameraEvents(cameraId).whereType();
+
+ @override
+ Stream onCameraResolutionChanged(
+ int cameraId,
+ ) => _cameraEvents(cameraId).whereType();
+
+ @override
+ Stream onCameraClosing(int cameraId) =>
+ _cameraEvents(cameraId).whereType();
+
+ @override
+ Stream onCameraError(int cameraId) =>
+ _cameraEvents(cameraId).whereType();
+
+ @override
+ Stream onVideoRecordedEvent(int cameraId) =>
+ _cameraEvents(cameraId).whereType();
+
+ @override
+ Stream onDeviceOrientationChanged() =>
+ Stream.value(
+ const DeviceOrientationChangedEvent(DeviceOrientation.landscapeLeft),
+ );
+
+ /// Builds the camera preview widget for the given [cameraId].
+ ///
+ /// On macOS and Linux the native backend mirrors the texture, so the
+ /// [Texture] widget is returned as-is. On Windows, IMFCaptureEngine does
+ /// not mirror natively, so the texture is wrapped in a horizontal
+ /// [Transform] flip.
+ @override
+ Widget buildPreview(int cameraId) {
+ final textureId = _textureIds[cameraId];
+ if (textureId == null) {
+ throw CameraException(
+ 'buildPreview',
+ 'Camera $cameraId has no registered texture. '
+ 'Was createCamera called?',
+ );
+ }
+ final texture = Texture(textureId: textureId);
+ if (!Platform.isWindows) return texture;
+ return Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.diagonal3Values(-1, 1, 1),
+ child: texture,
+ );
+ }
+
+ @override
+ Future pausePreview(int cameraId) async {
+ try {
+ await _channel.invokeMethod('pausePreview', {'cameraId': cameraId});
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ @override
+ Future resumePreview(int cameraId) async {
+ try {
+ await _channel.invokeMethod('resumePreview', {
+ 'cameraId': cameraId,
+ });
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ /// Toggles horizontal mirroring on the live camera feed.
+ ///
+ /// On macOS, this sets `isVideoMirrored` on the AVCaptureConnection.
+ /// On Linux, this toggles the `videoflip` GStreamer element's method.
+ /// On Windows, this returns a platform `unsupported` error.
+ ///
+ /// Can be called while the camera is running, no restart needed.
+ /// Silently ignored via [MissingPluginException] if the native side
+ /// has no handler for this platform.
+ Future setMirror(int cameraId, bool mirrored) async {
+ try {
+ await _channel.invokeMethod('setMirror', {
+ 'cameraId': cameraId,
+ 'mirrored': mirrored,
+ });
+ } on MissingPluginException catch (_) {
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ @override
+ bool supportsImageStreaming() => true;
+
+ /// Returns a stream of [CameraImageData] frames from the camera.
+ ///
+ /// Image delivery uses a two-path architecture:
+ /// 1. **FFI path** (preferred): reads directly from a native shared buffer
+ /// via `dart:ffi` for minimal copies (1 per frame). When active, frames
+ /// bypass [_imageStreamControllers] entirely, so `_handleNativeCall`'s
+ /// `imageStreamFrame` branch is a no-op for that camera.
+ /// 2. **MethodChannel fallback**: if FFI setup fails (symbols not found),
+ /// frames are delivered through `_handleNativeCall` and stored in
+ /// [_imageStreamControllers].
+ ///
+ /// The stream handle returned by native `startImageStream` may be an int
+ /// directly or a map containing a `streamHandle` key. Falls back to
+ /// [cameraId] for backward compatibility with older native implementations.
+ @override
+ Stream onStreamedFrameAvailable(
+ int cameraId, {
+ CameraImageStreamOptions? options,
+ }) {
+ int extractStreamHandle(dynamic value) {
+ if (value is int) return value;
+ if (value is Map) {
+ final dynamic raw = value['streamHandle'];
+ if (raw is int) return raw;
+ }
+ return cameraId;
+ }
+
+ ImageStreamPoller? ffi;
+ int streamHandle = cameraId;
+ late final StreamController controller;
+
+ controller = StreamController(
+ onListen: () async {
+ // Register the active stream up front so a concurrent dispose() can
+ // find and tear it down even while startImageStream is still in flight.
+ final active = _ActiveImageStream(controller);
+ _activeImageStreams[cameraId] = active;
+
+ final dynamic value = await _channel.invokeMethod(
+ 'startImageStream',
+ {'cameraId': cameraId},
+ );
+ streamHandle = extractStreamHandle(value);
+
+ // dispose() may have run while we awaited startImageStream. If so, do
+ // not start polling — the camera is already being torn down.
+ if (active.tornDown) return;
+
+ ffi = imageStreamPollerFactory(streamHandle);
+ active.ffi = ffi;
+ if (ffi == null) {
+ _imageStreamControllers[cameraId] = controller;
+ } else {
+ ffi!.start(controller);
+ }
+ },
+ onCancel: () async {
+ final active = _activeImageStreams.remove(cameraId);
+ // If dispose() already took over teardown, it owns the native stop and
+ // FFI cleanup. Just ensure the local poller is stopped and bail, so we
+ // never call stopImageStream on an already-disposed camera.
+ if (active == null || active.tornDown) {
+ ffi?.stop();
+ ffi?.dispose();
+ return;
+ }
+
+ // Unregister the native callback first so no new frames are dispatched.
+ ffi?.stop();
+ _imageStreamControllers.remove(cameraId);
+ // Tell native to stop streaming. Wrapped defensively: the camera may
+ // have been disposed between the check above and this call.
+ try {
+ await _channel.invokeMethod('stopImageStream', {
+ 'cameraId': cameraId,
+ 'streamHandle': streamHandle,
+ });
+ } on PlatformException catch (_) {}
+ // Native has stopped, safe to release FFI resources.
+ ffi?.dispose();
+ },
+ onPause: () {},
+ onResume: () {},
+ );
+
+ return controller.stream;
+ }
+
+ @override
+ Future takePicture(int cameraId) async {
+ try {
+ final path = await _channel.invokeMethod('takePicture', {
+ 'cameraId': cameraId,
+ });
+ return XFile(path!);
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ /// No-op on desktop, no preparation needed before recording.
+ @override
+ Future prepareForVideoRecording() async {}
+
+ @override
+ Future startVideoCapturing(VideoCaptureOptions options) async {
+ if (options.streamCallback != null) {
+ throw CameraException(
+ 'startVideoCapturing',
+ 'Simultaneous recording and streaming via streamCallback is not yet supported on desktop. Use onStreamedFrameAvailable() and startVideoRecording() separately.',
+ );
+ }
+ await startVideoRecording(options.cameraId);
+ }
+
+ @override
+ Future startVideoRecording(
+ int cameraId, {
+ Duration? maxVideoDuration,
+ }) async {
+ try {
+ await _channel.invokeMethod('startVideoRecording', {
+ 'cameraId': cameraId,
+ if (maxVideoDuration != null)
+ 'maxVideoDuration': maxVideoDuration.inMilliseconds,
+ });
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ @override
+ Future stopVideoRecording(int cameraId) async {
+ try {
+ final dynamic value = await _channel.invokeMethod(
+ 'stopVideoRecording',
+ {'cameraId': cameraId},
+ );
+ if (value is String) {
+ return XFile(value);
+ }
+ final map = value as Map;
+ final path = map['path'] as String?;
+ if (path == null || path.isEmpty) {
+ throw CameraException(
+ 'stopVideoRecording',
+ 'Native stopVideoRecording returned no output path.',
+ );
+ }
+ return XFile(path);
+ } on PlatformException catch (e) {
+ throw CameraException(e.code, e.message);
+ }
+ }
+
+ @override
+ Future pauseVideoRecording(int cameraId) async {
+ throw CameraException(
+ 'pauseVideoRecording',
+ 'Pausing video recording is not supported on desktop.',
+ );
+ }
+
+ @override
+ Future resumeVideoRecording(int cameraId) async {
+ throw CameraException(
+ 'resumeVideoRecording',
+ 'Resuming video recording is not supported on desktop.',
+ );
+ }
+
+ /// No-op for [FlashMode.off]; desktop cameras typically lack flash hardware.
+ @override
+ Future setFlashMode(int cameraId, FlashMode mode) async {
+ if (mode == FlashMode.off) {
+ return;
+ }
+ throw CameraException(
+ 'setFlashMode',
+ 'Flash mode is not supported on desktop.',
+ );
+ }
+
+ /// No-op for [ExposureMode.auto] (the default); throws otherwise.
+ @override
+ Future setExposureMode(int cameraId, ExposureMode mode) async {
+ if (mode == ExposureMode.auto) return;
+ throw CameraException(
+ 'setExposureMode',
+ 'Exposure mode control is not supported on desktop.',
+ );
+ }
+
+ @override
+ Future setExposurePoint(int cameraId, Point? point) async {
+ throw CameraException(
+ 'setExposurePoint',
+ 'Exposure point is not supported on desktop.',
+ );
+ }
+
+ @override
+ Future getMinExposureOffset(int cameraId) async => 0.0;
+
+ @override
+ Future getMaxExposureOffset(int cameraId) async => 0.0;
+
+ @override
+ Future getExposureOffsetStepSize(int cameraId) async => 0.0;
+
+ @override
+ Future setExposureOffset(int cameraId, double offset) async => 0.0;
+
+ /// No-op for [FocusMode.auto] (the default); throws otherwise.
+ @override
+ Future setFocusMode(int cameraId, FocusMode mode) async {
+ if (mode == FocusMode.auto) return;
+ throw CameraException(
+ 'setFocusMode',
+ 'Focus mode control is not supported on desktop.',
+ );
+ }
+
+ @override
+ Future setFocusPoint(int cameraId, Point? point) async {
+ throw CameraException(
+ 'setFocusPoint',
+ 'Focus point is not supported on desktop.',
+ );
+ }
+
+ @override
+ Future getMinZoomLevel(int cameraId) async => 1.0;
+
+ @override
+ Future getMaxZoomLevel(int cameraId) async => 1.0;
+
+ @override
+ Future setZoomLevel(int cameraId, double zoom) async {
+ if (zoom != 1.0) {
+ throw CameraException(
+ 'setZoomLevel',
+ 'Zoom is not supported on desktop. Only 1.0 is accepted.',
+ );
+ }
+ }
+
+ /// No-op on desktop, orientation locking is not applicable.
+ @override
+ Future lockCaptureOrientation(
+ int cameraId,
+ DeviceOrientation orientation,
+ ) async {}
+
+ /// No-op on desktop, orientation locking is not applicable.
+ @override
+ Future unlockCaptureOrientation(int cameraId) async {}
+
+ @override
+ Future setDescriptionWhileRecording(
+ CameraDescription description,
+ ) async {
+ throw CameraException(
+ 'setDescriptionWhileRecording',
+ 'Switching camera during recording is not supported on desktop.',
+ );
+ }
+}
+
+/// Tracks a single active image stream so [CameraDesktopPlugin.dispose] can tear
+/// it down even when its subscription was never cancelled.
+class _ActiveImageStream {
+ _ActiveImageStream(this.controller);
+
+ /// The controller backing the camera's image stream.
+ final StreamController controller;
+
+ /// The FFI poller, if the FFI fast path is active (null on the fallback path).
+ ImageStreamPoller? ffi;
+
+ /// Set once [CameraDesktopPlugin.dispose] has taken over teardown, so
+ /// `onListen`/`onCancel` know not to start polling or to double-stop native.
+ bool tornDown = false;
+}
diff --git a/plugins/camera_desktop/lib/src/camera_desktop_stub.dart b/plugins/camera_desktop/lib/src/camera_desktop_stub.dart
new file mode 100644
index 0000000..0de7855
--- /dev/null
+++ b/plugins/camera_desktop/lib/src/camera_desktop_stub.dart
@@ -0,0 +1,18 @@
+/// Flutter plugin registration stub for Dart-only initialization.
+///
+/// This class is automatically used by Flutter's plugin registration system
+/// to initialize the camera_desktop plugin on platforms that support
+/// Dart-only plugins.
+///
+/// **Note:** End users do not need to interact with this class directly.
+/// Flutter handles plugin registration automatically.
+class CameraDesktopDart {
+ /// Creates an instance for Dart-only plugin registration.
+ const CameraDesktopDart();
+
+ /// Registers the Dart implementation of this plugin.
+ ///
+ /// Called automatically by Flutter during plugin initialization.
+ /// Do not call this method directly.
+ static void registerWith() {}
+}
diff --git a/plugins/camera_desktop/lib/src/camera_desktop_web.dart b/plugins/camera_desktop/lib/src/camera_desktop_web.dart
new file mode 100644
index 0000000..8cb221e
--- /dev/null
+++ b/plugins/camera_desktop/lib/src/camera_desktop_web.dart
@@ -0,0 +1,5 @@
+/// Web plugin registration stub.
+class CameraDesktopWeb {
+ /// No-op registration, camera_desktop is a desktop-only plugin.
+ static void registerWith(dynamic registrar) {}
+}
diff --git a/plugins/camera_desktop/lib/src/image_stream_ffi.dart b/plugins/camera_desktop/lib/src/image_stream_ffi.dart
new file mode 100644
index 0000000..e05a71f
--- /dev/null
+++ b/plugins/camera_desktop/lib/src/image_stream_ffi.dart
@@ -0,0 +1,292 @@
+import 'dart:async';
+import 'dart:ffi';
+import 'dart:io';
+
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:flutter/foundation.dart';
+
+/// FFI struct matching the native ImageStreamBuffer layout (32-byte header).
+///
+/// Layout:
+/// int64_t sequence (offset 0)
+/// int32_t width (offset 8)
+/// int32_t height (offset 12)
+/// int32_t bytes_per_row (offset 16)
+/// int32_t format (offset 20) -- 0=BGRA, 1=RGBA
+/// int32_t ready (offset 24) -- 1=Dart may read, 0=native writing
+/// int32_t _pad (offset 28)
+/// uint8_t pixels[] (offset 32)
+final class ImageStreamBuffer extends Struct {
+ /// Frame sequence number, incremented by native code for each new frame.
+ @Int64()
+ external int sequence;
+
+ /// Frame width in pixels.
+ @Int32()
+ external int width;
+
+ /// Frame height in pixels.
+ @Int32()
+ external int height;
+
+ /// Number of bytes per row (may include padding beyond width * 4).
+ @Int32()
+ external int bytesPerRow;
+
+ /// Pixel format: 0 = BGRA (macOS), 1 = RGBA (Linux/Windows).
+ @Int32()
+ external int format;
+
+ /// Ready flag: 1 = Dart may read, 0 = native is writing.
+ @Int32()
+ external int ready;
+
+ /// Padding for 8-byte alignment.
+ @Int32()
+ // ignore: unused_field
+ external int _pad;
+}
+
+/// Native function signature for retrieving the shared image buffer pointer.
+typedef _GetBufferNative = Pointer Function(Int64 streamHandle);
+
+/// Dart-side function type for [_GetBufferNative].
+typedef _GetBufferDart = Pointer Function(int streamHandle);
+
+/// Native function signature for registering a frame-ready callback.
+typedef _RegisterCallbackNative =
+ Void Function(
+ Int64 streamHandle,
+ Pointer> callback,
+ );
+
+/// Dart-side function type for [_RegisterCallbackNative].
+typedef _RegisterCallbackDart =
+ void Function(
+ int streamHandle,
+ Pointer> callback,
+ );
+
+/// Native function signature for unregistering a frame-ready callback.
+typedef _UnregisterCallbackNative = Void Function(Int64 streamHandle);
+
+/// Dart-side function type for [_UnregisterCallbackNative].
+typedef _UnregisterCallbackDart = void Function(int streamHandle);
+
+/// Minimal interface for an image-stream frame poller, so the plugin can hold
+/// either a real [ImageStreamFfi] or a test fake.
+abstract interface class ImageStreamPoller {
+ /// Begins delivering frames to [controller].
+ void start(StreamController controller);
+
+ /// Stops delivering frames (cancels the poll timer).
+ void stop();
+
+ /// Releases all resources.
+ void dispose();
+}
+
+/// Manages FFI-based image stream for a single camera.
+///
+/// Instead of receiving frame data through MethodChannel serialization
+/// (3 copies per frame), this reads directly from a native shared buffer
+/// via dart:ffi (1 copy per frame, into a Dart-owned Uint8List).
+///
+/// If FFI setup fails (symbols not found, library not loadable), returns
+/// null from [tryCreate] and the caller falls back to MethodChannel.
+class ImageStreamFfi implements ImageStreamPoller {
+ ImageStreamFfi._(
+ this._streamHandle,
+ this._getBuffer,
+ this._registerCallback,
+ this._unregisterCallback,
+ this._nativeNoopCallback,
+ );
+
+ /// The native stream handle used to identify this stream to native code.
+ final int _streamHandle;
+
+ /// FFI function to retrieve the shared buffer pointer.
+ final _GetBufferDart _getBuffer;
+
+ /// FFI function to register a frame-ready callback with native code.
+ final _RegisterCallbackDart _registerCallback;
+
+ /// FFI function to unregister the frame-ready callback.
+ final _UnregisterCallbackDart _unregisterCallback;
+
+ /// A native no-op callback symbol.
+ ///
+ /// Registered with native to keep the shared-buffer FFI fast path active
+ /// without storing a Dart callback trampoline that can become invalid after
+ /// hot restart.
+ final Pointer> _nativeNoopCallback;
+
+ /// Polls the shared buffer for new sequence numbers.
+ Timer? _pollTimer;
+
+ /// Prevents re-entrant polling when frame decoding/copying takes longer than
+ /// the poll interval.
+ bool _pollInProgress = false;
+
+ /// The stream controller to which decoded frames are added.
+ StreamController? _controller;
+
+ /// The sequence number of the last frame delivered, used to skip duplicates.
+ int _lastSequence = 0;
+
+ /// Number of poll ticks executed since [start]. Test/diagnostic hook used to
+ /// verify the poller actually stops after the stream is torn down.
+ @visibleForTesting
+ int pollCount = 0;
+
+ /// Attempts to set up the FFI image stream.
+ ///
+ /// Returns null if the native library or required symbols cannot be found,
+ /// allowing the caller to fall back to MethodChannel frame delivery.
+ static ImageStreamFfi? tryCreate(int streamHandle) {
+ try {
+ final lib = _loadNativeLibrary();
+
+ final getBuffer = lib.lookupFunction<_GetBufferNative, _GetBufferDart>(
+ 'camera_desktop_get_image_stream_buffer',
+ );
+ final registerCallback = lib
+ .lookupFunction<_RegisterCallbackNative, _RegisterCallbackDart>(
+ 'camera_desktop_register_image_stream_callback',
+ );
+ final unregisterCallback = lib
+ .lookupFunction<_UnregisterCallbackNative, _UnregisterCallbackDart>(
+ 'camera_desktop_unregister_image_stream_callback',
+ );
+ final nativeNoopCallback = lib
+ .lookup>(
+ 'camera_desktop_image_stream_noop_callback',
+ );
+
+ return ImageStreamFfi._(
+ streamHandle,
+ getBuffer,
+ registerCallback,
+ unregisterCallback,
+ nativeNoopCallback,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ /// Loads the native library containing the FFI image stream symbols.
+ ///
+ /// On all desktop platforms, the plugin's native code is compiled into a
+ /// shared library loaded by the Flutter engine. [DynamicLibrary.process]
+ /// searches the current process's symbol table. On Windows, falls back to
+ /// explicitly opening `camera_desktop_plugin.dll` if process lookup fails.
+ static DynamicLibrary _loadNativeLibrary() {
+ if (Platform.isMacOS || Platform.isLinux) {
+ return DynamicLibrary.process();
+ }
+ if (Platform.isWindows) {
+ try {
+ return DynamicLibrary.process();
+ } catch (_) {
+ return DynamicLibrary.open('camera_desktop_plugin.dll');
+ }
+ }
+ throw UnsupportedError('Unsupported platform for FFI image stream');
+ }
+
+ /// Registers a native no-op callback and starts polling the shared buffer.
+ ///
+ /// Using a native callback symbol (instead of [NativeCallable.listener])
+ /// avoids stale Dart callback metadata crashes during hot restart.
+ @override
+ void start(StreamController controller) {
+ _controller = controller;
+ _lastSequence = 0;
+ _pollInProgress = false;
+
+ _registerCallback(_streamHandle, _nativeNoopCallback);
+ _pollTimer?.cancel();
+ _pollTimer = Timer.periodic(
+ const Duration(milliseconds: 8),
+ (_) => _pollForFrame(),
+ );
+ _pollForFrame();
+ }
+
+ /// Polls for one new frame and emits it if sequence has advanced.
+ void _pollForFrame() {
+ pollCount++;
+ if (_pollInProgress) return;
+ _pollInProgress = true;
+ try {
+ _readLatestFrame();
+ } finally {
+ _pollInProgress = false;
+ }
+ }
+
+ /// Reads the shared buffer, skips duplicate
+ /// frames by comparing sequence numbers, creates a zero-copy view over
+ /// the native pixel buffer, then copies into a Dart-owned [Uint8List]
+ /// (1 copy, required by the platform interface contract).
+ void _readLatestFrame() {
+ final controller = _controller;
+ if (controller == null || controller.isClosed) return;
+
+ final bufPtr = _getBuffer(_streamHandle);
+ if (bufPtr == nullptr) return;
+
+ final buf = bufPtr.cast().ref;
+ if (buf.ready != 1) return;
+
+ if (buf.sequence <= _lastSequence) return;
+ _lastSequence = buf.sequence;
+
+ final width = buf.width;
+ final height = buf.height;
+ final bytesPerRow = buf.bytesPerRow;
+ final format = buf.format;
+ final dataSize = bytesPerRow * height;
+
+ final pixelsPtr = bufPtr.cast() + sizeOf();
+ final nativeView = pixelsPtr.asTypedList(dataSize);
+
+ final bytes = Uint8List.fromList(nativeView);
+
+ final rawFormat = format == 0 ? 'BGRA' : 'RGBA';
+
+ controller.add(
+ CameraImageData(
+ format: CameraImageFormat(ImageFormatGroup.bgra8888, raw: rawFormat),
+ width: width,
+ height: height,
+ planes: [
+ CameraImagePlane(
+ bytes: bytes,
+ bytesPerRow: bytesPerRow,
+ bytesPerPixel: 4,
+ width: width,
+ height: height,
+ ),
+ ],
+ ),
+ );
+ }
+
+ /// Unregisters the native callback.
+ @override
+ void stop() {
+ _pollTimer?.cancel();
+ _pollTimer = null;
+ _unregisterCallback(_streamHandle);
+ }
+
+ /// Releases all resources.
+ @override
+ void dispose() {
+ stop();
+ _controller = null;
+ }
+}
diff --git a/plugins/camera_desktop/linux/CMakeLists.txt b/plugins/camera_desktop/linux/CMakeLists.txt
new file mode 100644
index 0000000..a0521cd
--- /dev/null
+++ b/plugins/camera_desktop/linux/CMakeLists.txt
@@ -0,0 +1,54 @@
+cmake_minimum_required(VERSION 3.10)
+
+set(PROJECT_NAME "camera_desktop")
+project(${PROJECT_NAME} LANGUAGES CXX)
+
+set(PLUGIN_NAME "camera_desktop_plugin")
+
+list(APPEND PLUGIN_SOURCES
+ "camera_desktop_plugin.cc"
+ "camera_texture.cc"
+ "camera.cc"
+ "device_enumerator.cc"
+ "photo_handler.cc"
+ "record_handler.cc"
+ "image_stream_ffi.cc"
+ "pipewire_portal.cc"
+)
+
+add_library(${PLUGIN_NAME} SHARED
+ ${PLUGIN_SOURCES}
+)
+
+apply_standard_settings(${PLUGIN_NAME})
+
+set_target_properties(${PLUGIN_NAME} PROPERTIES
+ CXX_VISIBILITY_PRESET hidden)
+target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
+
+target_include_directories(${PLUGIN_NAME} INTERFACE
+ "${CMAKE_CURRENT_SOURCE_DIR}/include")
+target_include_directories(${PLUGIN_NAME} PRIVATE
+ "${CMAKE_CURRENT_SOURCE_DIR}")
+
+target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
+target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
+
+# GStreamer dependencies
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(GSTREAMER REQUIRED
+ gstreamer-1.0
+ gstreamer-app-1.0
+ gstreamer-video-1.0
+)
+target_include_directories(${PLUGIN_NAME} PRIVATE ${GSTREAMER_INCLUDE_DIRS})
+target_link_libraries(${PLUGIN_NAME} PRIVATE ${GSTREAMER_LIBRARIES})
+
+set(camera_desktop_bundled_libraries
+ ""
+ PARENT_SCOPE
+)
+
+if(include_camera_desktop_tests)
+ add_subdirectory(test)
+endif()
diff --git a/plugins/camera_desktop/linux/camera.cc b/plugins/camera_desktop/linux/camera.cc
new file mode 100644
index 0000000..231bd31
--- /dev/null
+++ b/plugins/camera_desktop/linux/camera.cc
@@ -0,0 +1,771 @@
+#include "camera.h"
+#include "photo_handler.h"
+
+#include
+#include
+
+#include
+#include
+#include
+
+static const guint kInitTimeoutMs = 8000;
+
+Camera::Camera(int camera_id,
+ FlTextureRegistrar* texture_registrar,
+ FlMethodChannel* method_channel,
+ const CameraConfig& config)
+ : camera_id_(camera_id),
+ texture_id_(-1),
+ state_(CameraState::kCreated),
+ config_(config),
+ texture_registrar_(texture_registrar),
+ method_channel_(method_channel),
+ texture_(nullptr),
+ pipeline_(nullptr),
+ tee_(nullptr),
+ appsink_(nullptr),
+ videoflip_(nullptr),
+ bus_watch_id_(0),
+ init_timeout_id_(0),
+ record_handler_(std::make_unique()),
+ pending_init_call_(nullptr),
+ first_frame_received_(false),
+ preview_paused_(false),
+ image_streaming_(false),
+ image_stream_callback_(nullptr),
+ actual_width_(0),
+ actual_height_(0) {}
+
+Camera::~Camera() {
+ Dispose();
+}
+
+int64_t Camera::RegisterTexture() {
+ texture_ = camera_texture_new();
+ FlTexture* fl_tex = camera_texture_as_fl_texture(texture_);
+ if (!fl_texture_registrar_register_texture(texture_registrar_, fl_tex)) {
+ g_info("[camera_desktop] Camera %d: failed to register Flutter texture",
+ camera_id_);
+ g_object_unref(texture_);
+ texture_ = nullptr;
+ return -1;
+ }
+ texture_id_ = fl_texture_get_id(fl_tex);
+ return texture_id_;
+}
+
+void Camera::Initialize(FlMethodCall* method_call) {
+ if (state_.load() != CameraState::kCreated) {
+ g_info("[camera_desktop] Camera %d: Initialize called in unexpected state %d",
+ camera_id_, static_cast(state_.load()));
+ g_autoptr(FlValue) error_details = fl_value_new_null();
+ fl_method_call_respond_error(method_call, "already_initialized",
+ "Camera is already initialized or disposed",
+ error_details, nullptr);
+ return;
+ }
+
+ state_.store(CameraState::kInitializing);
+ pending_init_call_ = FL_METHOD_CALL(g_object_ref(method_call));
+ first_frame_received_.store(false);
+
+ g_info("[camera_desktop] Initializing camera %d (%s backend, %dx%d@%dfps)",
+ camera_id_,
+ config_.backend == CameraBackend::kPipeWire ? "PipeWire" : "V4L2",
+ config_.target_width, config_.target_height, config_.target_fps);
+
+ GError* error = nullptr;
+ if (!BuildPipeline(&error)) {
+ g_info("[camera_desktop] Camera %d: BuildPipeline failed: %s", camera_id_,
+ error ? error->message : "unknown error");
+ RespondToPendingInit(false, error->message);
+ g_error_free(error);
+ state_.store(CameraState::kCreated);
+ return;
+ }
+
+ // Set pipeline to PLAYING.
+ GstStateChangeReturn ret =
+ gst_element_set_state(pipeline_, GST_STATE_PLAYING);
+ if (ret == GST_STATE_CHANGE_FAILURE) {
+ g_info("[camera_desktop] Camera %d: gst_element_set_state(PLAYING) failed",
+ camera_id_);
+ RespondToPendingInit(false, "Failed to start GStreamer pipeline");
+ gst_object_unref(pipeline_);
+ pipeline_ = nullptr;
+ appsink_ = nullptr;
+ state_.store(CameraState::kCreated);
+ return;
+ }
+
+ // Set a timeout for initialization, if no frame arrives in time, fail.
+ init_timeout_id_ =
+ g_timeout_add(kInitTimeoutMs, Camera::OnInitTimeout, this);
+}
+
+bool Camera::BuildPipeline(GError** error) {
+ // Build pipeline with a tee to support branching for recording:
+ // [source] ! videoconvert ! videoscale ! videorate ! caps ! tee name=t
+ // t. ! queue ! appsink (preview)
+ // t. ! [recording branch, added later by RecordHandler]
+ //
+ // videoscale/videorate let v4l2src negotiate a native mode (e.g. MJPEG or a
+ // lower YUYV resolution) instead of failing not-negotiated when the device
+ // cannot output the target size/fps in uncompressed YUV (common on USB cams).
+ const int w = config_.target_width;
+ const int h = config_.target_height;
+ const int fps = config_.target_fps;
+
+ gchar preview_tail[512];
+ g_snprintf(
+ preview_tail, sizeof(preview_tail),
+ "! videoflip name=flip method=horizontal-flip "
+ "! videoscale ! videorate "
+ "! video/x-raw,format=RGBA,width=%d,height=%d,framerate=%d/1 "
+ "! tee name=t "
+ "t. ! queue name=preview_queue ! "
+ "appsink name=sink emit-signals=true max-buffers=2 drop=true "
+ "sync=false",
+ w, h, fps);
+
+ gchar* pipeline_str = nullptr;
+
+ if (config_.backend == CameraBackend::kPipeWire) {
+ // PipeWire portal path: use pipewiresrc with the portal-provided fd.
+ std::string pw_node_id = config_.device_path.substr(3);
+ pipeline_str = g_strdup_printf(
+ "pipewiresrc fd=%d path=%s do-timestamp=true "
+ "! videoconvert "
+ "%s",
+ config_.pw_fd, pw_node_id.c_str(), preview_tail);
+ } else {
+ // V4L2: prefer MJPEG at the target size when the device actually supports
+ // it (native 720p/1080p on many USB cameras, and the only way some can
+ // deliver high resolutions within USB 2.0 bandwidth). The choice must be
+ // probed up front: gst_parse_launch() succeeds even for an MJPEG pipeline
+ // the camera cannot satisfy, so a non-MJPEG camera would otherwise fail at
+ // PLAYING with a runtime "not-negotiated" error instead of falling back.
+ // Only width/height are pinned on the MJPEG caps; the native frame rate is
+ // left to float and the downstream videorate adapts it to the target fps,
+ // so requesting an fps the camera does not offer natively in MJPEG does not
+ // break negotiation. Cameras exposing only raw formats (e.g. NV12/YUYV) use
+ // the unconstrained capture + scale path, which negotiates any native mode
+ // and adapts it to the target via videoscale/videorate.
+ if (DeviceEnumerator::SupportsMjpeg(config_.device_path, w, h)) {
+ pipeline_str = g_strdup_printf(
+ "v4l2src device=%s "
+ "! image/jpeg,width=%d,height=%d "
+ "! jpegdec ! videoconvert "
+ "%s",
+ config_.device_path.c_str(), w, h, preview_tail);
+ } else {
+ pipeline_str = g_strdup_printf("v4l2src device=%s "
+ "! videoconvert "
+ "%s",
+ config_.device_path.c_str(), preview_tail);
+ }
+ }
+
+ if (pipeline_str) {
+ g_info("[camera_desktop] Pipeline: %s", pipeline_str);
+ pipeline_ = gst_parse_launch(pipeline_str, error);
+ g_free(pipeline_str);
+ }
+
+ if (!pipeline_) {
+ return false;
+ }
+
+ // Get the tee element (needed for recording branch attachment).
+ tee_ = gst_bin_get_by_name(GST_BIN(pipeline_), "t");
+ if (!tee_) {
+ g_info("[camera_desktop] Camera %d: tee element not found in pipeline",
+ camera_id_);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "Failed to find tee in pipeline");
+ gst_object_unref(pipeline_);
+ pipeline_ = nullptr;
+ return false;
+ }
+ // Release our ref (pipeline holds one).
+ gst_object_unref(tee_);
+
+ // Get the videoflip element for runtime mirror toggling.
+ videoflip_ = gst_bin_get_by_name(GST_BIN(pipeline_), "flip");
+ if (videoflip_) {
+ gst_object_unref(videoflip_); // Pipeline holds the ref.
+ } else {
+ g_info("[camera_desktop] Camera %d: videoflip element not found in pipeline"
+ " (mirror toggling will be unavailable)", camera_id_);
+ }
+
+ // Get the appsink element.
+ appsink_ = gst_bin_get_by_name(GST_BIN(pipeline_), "sink");
+ if (!appsink_) {
+ g_info("[camera_desktop] Camera %d: appsink element not found in pipeline",
+ camera_id_);
+ g_set_error(error, G_IO_ERROR, G_IO_ERROR_FAILED,
+ "Failed to find appsink in pipeline");
+ gst_object_unref(pipeline_);
+ pipeline_ = nullptr;
+ return false;
+ }
+
+ // Connect the new-sample signal.
+ GstAppSinkCallbacks callbacks = {};
+ callbacks.new_sample = Camera::OnNewSample;
+ gst_app_sink_set_callbacks(GST_APP_SINK(appsink_), &callbacks, this,
+ nullptr);
+
+ // Set up bus watch for error messages.
+ GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline_));
+ bus_watch_id_ = gst_bus_add_watch(bus, Camera::OnBusMessage, this);
+ gst_object_unref(bus);
+
+ // Release our ref on the appsink (pipeline holds one).
+ gst_object_unref(appsink_);
+
+ return true;
+}
+
+void Camera::RespondToPendingInit(bool success, const char* error_message) {
+ if (!pending_init_call_) return;
+
+ // Cancel the timeout.
+ if (init_timeout_id_ > 0) {
+ g_source_remove(init_timeout_id_);
+ init_timeout_id_ = 0;
+ }
+
+ if (success) {
+ g_autoptr(FlValue) result = fl_value_new_map();
+ fl_value_set_string_take(result, "previewWidth",
+ fl_value_new_float((double)actual_width_.load()));
+ fl_value_set_string_take(result, "previewHeight",
+ fl_value_new_float((double)actual_height_.load()));
+ fl_method_call_respond_success(pending_init_call_, result, nullptr);
+ } else {
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(pending_init_call_, "initialization_failed",
+ error_message ? error_message : "Unknown error",
+ details, nullptr);
+ }
+
+ g_object_unref(pending_init_call_);
+ pending_init_call_ = nullptr;
+}
+
+GstFlowReturn Camera::OnNewSample(GstAppSink* sink, gpointer user_data) {
+ Camera* self = static_cast(user_data);
+
+ GstSample* sample = gst_app_sink_pull_sample(sink);
+ if (!sample) {
+ g_warning("[camera_desktop] OnNewSample: gst_app_sink_pull_sample returned null");
+ return GST_FLOW_ERROR;
+ }
+
+ GstBuffer* buffer = gst_sample_get_buffer(sample);
+ GstCaps* caps = gst_sample_get_caps(sample);
+
+ GstVideoInfo info;
+ if (!gst_video_info_from_caps(&info, caps)) {
+ g_warning("[camera_desktop] OnNewSample: gst_video_info_from_caps failed");
+ gst_sample_unref(sample);
+ return GST_FLOW_ERROR;
+ }
+
+ int width = GST_VIDEO_INFO_WIDTH(&info);
+ int height = GST_VIDEO_INFO_HEIGHT(&info);
+ int stride = GST_VIDEO_INFO_PLANE_STRIDE(&info, 0);
+
+ GstMapInfo map;
+ if (!gst_buffer_map(buffer, &map, GST_MAP_READ)) {
+ g_warning("[camera_desktop] OnNewSample: gst_buffer_map failed");
+ gst_sample_unref(sample);
+ return GST_FLOW_ERROR;
+ }
+
+ // Handle first-frame initialization response.
+ // C-2: first_frame_received_ is atomic, safe cross-thread read/write.
+ bool is_first_frame = !self->first_frame_received_.load();
+ if (is_first_frame) {
+ g_info("[camera_desktop] Camera %d: first frame received (%dx%d)",
+ self->camera_id_, width, height);
+ self->first_frame_received_.store(true);
+ // H-2: actual_width_/height_ are atomic, safe cross-thread write.
+ self->actual_width_.store(width);
+ self->actual_height_.store(height);
+ self->state_.store(CameraState::kRunning);
+ }
+
+ // Update the texture only if preview is not paused (or if this is the first
+ // frame, which we need for initialization).
+ // C-3: preview_paused_ is atomic, safe cross-thread read.
+ if (!self->preview_paused_.load() || is_first_frame) {
+ if (stride == width * 4) {
+ // No padding, direct copy.
+ camera_texture_update(self->texture_, map.data, width, height);
+ } else {
+ // Stride has padding, copy row-by-row into a tight buffer.
+ // M-1 note: this intermediate allocation is unavoidable here since
+ // camera_texture_update requires a tightly-packed buffer.
+ size_t tight_size = (size_t)width * height * 4;
+ uint8_t* tight = (uint8_t*)g_malloc(tight_size);
+ for (int row = 0; row < height; row++) {
+ memcpy(tight + row * width * 4, map.data + row * stride, width * 4);
+ }
+ camera_texture_update(self->texture_, tight, width, height);
+ g_free(tight);
+ }
+
+ // Notify Flutter that a new frame is available.
+ fl_texture_registrar_mark_texture_frame_available(
+ self->texture_registrar_,
+ camera_texture_as_fl_texture(self->texture_));
+ }
+
+ // Send frame to Dart image stream if streaming is active.
+ if (self->image_streaming_.load()) {
+ // C-4: load the callback pointer atomically once, then use the local copy.
+ // This prevents a TOCTOU race where the pointer is nulled between the
+ // check and the call.
+ ImageStreamCallback cb = self->image_stream_callback_.load();
+ if (cb) {
+ // FFI path: write to shared buffer, notify Dart directly.
+ size_t frame_size = (size_t)width * height * 4;
+ size_t total_size = offsetof(Camera::ImageStreamBuffer, pixels) + frame_size;
+
+ if (self->image_stream_buffer_size_ < total_size) {
+ g_free(self->image_stream_buffer_);
+ self->image_stream_buffer_ =
+ (Camera::ImageStreamBuffer*)g_malloc(total_size);
+ self->image_stream_buffer_size_ = total_size;
+ }
+
+ auto* buf = self->image_stream_buffer_;
+ buf->ready = 0;
+
+ if (stride == width * 4) {
+ memcpy(buf->pixels, map.data, frame_size);
+ } else {
+ for (int row = 0; row < height; row++) {
+ memcpy(buf->pixels + row * width * 4, map.data + row * stride,
+ width * 4);
+ }
+ }
+
+ buf->width = width;
+ buf->height = height;
+ buf->bytes_per_row = width * 4;
+ buf->format = 1; // RGBA (Linux GStreamer pipeline)
+ buf->sequence = ++self->image_stream_sequence_;
+
+ // C-5: release fence, guarantees all pixel and metadata writes above
+ // are visible to any thread that subsequently observes ready == 1.
+ std::atomic_thread_fence(std::memory_order_release);
+ buf->ready = 1;
+
+ cb(self->camera_id_);
+ } else {
+ // Legacy MethodChannel fallback path.
+ size_t frame_size = (size_t)width * height * 4;
+ uint8_t* frame_copy = (uint8_t*)g_malloc(frame_size);
+ if (stride == width * 4) {
+ memcpy(frame_copy, map.data, frame_size);
+ } else {
+ for (int row = 0; row < height; row++) {
+ memcpy(frame_copy + row * width * 4, map.data + row * stride,
+ width * 4);
+ }
+ }
+
+ struct ImageStreamData {
+ FlMethodChannel* channel;
+ int camera_id;
+ uint8_t* pixels;
+ int width;
+ int height;
+ size_t size;
+ };
+
+ auto* stream_data = new ImageStreamData();
+ stream_data->channel = self->method_channel_;
+ stream_data->camera_id = self->camera_id_;
+ stream_data->pixels = frame_copy;
+ stream_data->width = width;
+ stream_data->height = height;
+ stream_data->size = frame_size;
+
+ g_idle_add(
+ [](gpointer user_data) -> gboolean {
+ auto* data = static_cast(user_data);
+
+ g_autoptr(FlValue) args = fl_value_new_map();
+ fl_value_set_string_take(args, "cameraId",
+ fl_value_new_int(data->camera_id));
+ fl_value_set_string_take(args, "width",
+ fl_value_new_int(data->width));
+ fl_value_set_string_take(args, "height",
+ fl_value_new_int(data->height));
+ fl_value_set_string_take(
+ args, "bytes",
+ fl_value_new_uint8_list(data->pixels, data->size));
+
+ fl_method_channel_invoke_method(data->channel, "imageStreamFrame",
+ args, nullptr, nullptr, nullptr);
+
+ g_free(data->pixels);
+ delete data;
+ return G_SOURCE_REMOVE;
+ },
+ stream_data);
+ }
+ }
+
+ gst_buffer_unmap(buffer, &map);
+ gst_sample_unref(sample);
+
+ // Dispatch init response to the main thread (OnNewSample runs on the
+ // GStreamer streaming thread, but fl_method_call_respond_* must be called
+ // from the main GLib thread).
+ if (is_first_frame) {
+ g_idle_add(
+ [](gpointer user_data) -> gboolean {
+ Camera* cam = static_cast(user_data);
+ cam->RespondToPendingInit(true, nullptr);
+ return G_SOURCE_REMOVE;
+ },
+ self);
+ }
+
+ return GST_FLOW_OK;
+}
+
+gboolean Camera::OnBusMessage(GstBus* bus, GstMessage* msg,
+ gpointer user_data) {
+ Camera* self = static_cast(user_data);
+
+ switch (GST_MESSAGE_TYPE(msg)) {
+ case GST_MESSAGE_ERROR: {
+ GError* err = nullptr;
+ gchar* debug = nullptr;
+ gst_message_parse_error(msg, &err, &debug);
+
+ g_info("[camera_desktop] Camera %d: GStreamer error: %s (debug: %s)",
+ self->camera_id_,
+ err ? err->message : "unknown",
+ debug ? debug : "none");
+
+ // C-2: load state_ atomically.
+ CameraState s = self->state_.load();
+ if (s == CameraState::kInitializing) {
+ self->RespondToPendingInit(false, err->message);
+ self->state_.store(CameraState::kCreated);
+ } else if (s == CameraState::kRunning || s == CameraState::kPaused) {
+ self->SendError(err->message);
+ }
+
+ g_error_free(err);
+ g_free(debug);
+ break;
+ }
+ case GST_MESSAGE_EOS: {
+ // End of stream (e.g., device unplugged).
+ CameraState s = self->state_.load();
+ if (s == CameraState::kRunning || s == CameraState::kPaused) {
+ self->SendError("Camera stream ended unexpectedly");
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ return TRUE;
+}
+
+gboolean Camera::OnInitTimeout(gpointer user_data) {
+ Camera* self = static_cast(user_data);
+ self->init_timeout_id_ = 0;
+
+ if (self->state_.load() == CameraState::kInitializing) {
+ self->RespondToPendingInit(
+ false, "Camera initialization timed out, no frames received");
+ if (self->pipeline_) {
+ gst_element_set_state(self->pipeline_, GST_STATE_NULL);
+ }
+ self->state_.store(CameraState::kCreated);
+ }
+ return G_SOURCE_REMOVE;
+}
+
+void Camera::SendError(const std::string& description) {
+ g_autoptr(FlValue) args = fl_value_new_map();
+ fl_value_set_string_take(args, "cameraId",
+ fl_value_new_int(camera_id_));
+ fl_value_set_string_take(args, "description",
+ fl_value_new_string(description.c_str()));
+ fl_method_channel_invoke_method(method_channel_, "cameraError", args,
+ nullptr, nullptr, nullptr);
+}
+
+void Camera::TakePicture(FlMethodCall* method_call) {
+ CameraState s = state_.load();
+ if (s != CameraState::kRunning && s != CameraState::kPaused) {
+ g_info("[camera_desktop] Camera %d: TakePicture called but camera is not"
+ " running (state=%d)", camera_id_, static_cast(s));
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(method_call, "not_running",
+ "Camera is not running", details, nullptr);
+ return;
+ }
+
+ // Generate a unique temporary file path using an atomic sequence counter
+ // rather than the wall clock to prevent collisions under NTP corrections.
+ static std::atomic capture_seq{0};
+ gchar* tmp_path =
+ g_strdup_printf("%s/camera_desktop_%d_%" G_GINT64_FORMAT ".jpg",
+ g_get_tmp_dir(), camera_id_,
+ capture_seq.fetch_add(1, std::memory_order_relaxed));
+
+ // C-7: gst_video_convert_sample performs synchronous JPEG encoding which
+ // can take 30-200 ms at 1080p. Offload to a GLib thread-pool task so the
+ // main/UI thread is never blocked.
+ //
+ // Take a GStreamer reference to appsink_ so it stays alive for the duration
+ // of the task even if Dispose() is called concurrently.
+ struct TakePictureData {
+ GstElement* appsink; // holds a gst_object_ref
+ std::string output_path;
+ std::string error_message;
+ bool success;
+ FlMethodCall* method_call; // holds a g_object_ref
+ };
+
+ auto* d = new TakePictureData();
+ d->appsink = GST_ELEMENT(gst_object_ref(appsink_));
+ d->output_path = tmp_path;
+ d->success = false;
+ d->method_call = FL_METHOD_CALL(g_object_ref(method_call));
+ g_free(tmp_path);
+
+ GTask* task = g_task_new(nullptr, nullptr, nullptr, nullptr);
+ g_task_set_task_data(task, d, nullptr);
+ g_task_run_in_thread(
+ task,
+ [](GTask* /*task*/, gpointer /*source*/, gpointer task_data,
+ GCancellable* /*cancel*/) {
+ auto* d = static_cast(task_data);
+ GError* err = nullptr;
+ d->success = PhotoHandler::TakePicture(d->appsink, d->output_path, &err);
+ gst_object_unref(d->appsink);
+ d->appsink = nullptr;
+ if (!d->success && err) {
+ d->error_message = err->message;
+ g_error_free(err);
+ }
+ // Marshal the method-channel response back to the main GLib thread.
+ g_idle_add(
+ [](gpointer p) -> gboolean {
+ auto* d = static_cast(p);
+ if (d->success) {
+ g_autoptr(FlValue) result =
+ fl_value_new_string(d->output_path.c_str());
+ fl_method_call_respond_success(d->method_call, result, nullptr);
+ } else {
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(
+ d->method_call, "capture_failed",
+ d->error_message.empty() ? "Failed to capture image"
+ : d->error_message.c_str(),
+ details, nullptr);
+ }
+ g_object_unref(d->method_call);
+ delete d;
+ return G_SOURCE_REMOVE;
+ },
+ d);
+ });
+ g_object_unref(task);
+}
+
+void Camera::StartVideoRecording(FlMethodCall* method_call) {
+ CameraState s = state_.load();
+ if (s != CameraState::kRunning && s != CameraState::kPaused) {
+ g_info("[camera_desktop] Camera %d: StartVideoRecording called but camera"
+ " is not running (state=%d)", camera_id_, static_cast(s));
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(method_call, "not_running",
+ "Camera is not running", details, nullptr);
+ return;
+ }
+
+ // Set up the recording branch on first use.
+ if (!record_handler_->is_recording()) {
+ GError* error = nullptr;
+ // H-2: load actual dimensions atomically, they are written from the
+ // GStreamer streaming thread on first frame.
+ if (!record_handler_->Setup(pipeline_, tee_,
+ actual_width_.load(), actual_height_.load(),
+ config_.target_fps,
+ config_.target_bitrate,
+ config_.audio_bitrate,
+ config_.enable_audio, &error)) {
+ g_info("[camera_desktop] Camera %d: RecordHandler::Setup failed: %s",
+ camera_id_, error ? error->message : "unknown error");
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(
+ method_call, "recording_setup_failed",
+ error ? error->message : "Failed to set up recording", details,
+ nullptr);
+ if (error) g_error_free(error);
+ return;
+ }
+ if (config_.enable_audio && !record_handler_->has_audio()) {
+ SendError("Audio recording was requested but audio setup failed. "
+ "Recording will continue without audio.");
+ }
+ }
+
+ // H-6: derive extension from the muxer that was actually selected so the
+ // file's extension always matches its container format.
+ static std::atomic rec_seq{0};
+ gchar* tmp_path = g_strdup_printf(
+ "%s/camera_desktop_%d_%" G_GINT64_FORMAT ".%s",
+ g_get_tmp_dir(), camera_id_,
+ rec_seq.fetch_add(1, std::memory_order_relaxed),
+ record_handler_->output_extension());
+
+ GError* error = nullptr;
+ if (!record_handler_->StartRecording(tmp_path, &error)) {
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(
+ method_call, "recording_start_failed",
+ error ? error->message : "Failed to start recording", details,
+ nullptr);
+ if (error) g_error_free(error);
+ g_free(tmp_path);
+ return;
+ }
+
+ g_free(tmp_path);
+ fl_method_call_respond_success(method_call, fl_value_new_null(), nullptr);
+}
+
+void Camera::StopVideoRecording(FlMethodCall* method_call) {
+ if (!record_handler_->is_recording()) {
+ g_info("[camera_desktop] Camera %d: StopVideoRecording called but not"
+ " recording", camera_id_);
+ g_autoptr(FlValue) details = fl_value_new_null();
+ fl_method_call_respond_error(method_call, "not_recording",
+ "No recording in progress", details, nullptr);
+ return;
+ }
+
+ record_handler_->StopRecording(method_call);
+}
+
+void Camera::StartImageStream() {
+ g_info("[camera_desktop] Camera %d: starting image stream", camera_id_);
+ image_streaming_ = true;
+}
+
+void Camera::StopImageStream() {
+ g_info("[camera_desktop] Camera %d: stopping image stream", camera_id_);
+ image_streaming_ = false;
+}
+
+void Camera::RegisterImageStreamCallback(void (*callback)(int32_t)) {
+ // C-4: atomic store, safe to write from main thread while GStreamer thread
+ // reads. The GStreamer thread loads the pointer once per frame (see
+ // OnNewSample) so it cannot race between check and call.
+ image_stream_callback_.store(callback);
+}
+
+void Camera::UnregisterImageStreamCallback() {
+ image_stream_callback_.store(nullptr);
+}
+
+void Camera::PausePreview() {
+ g_info("[camera_desktop] Camera %d: pausing preview", camera_id_);
+ // C-3: atomic store, safe cross-thread write.
+ preview_paused_.store(true);
+}
+
+void Camera::ResumePreview() {
+ g_info("[camera_desktop] Camera %d: resuming preview", camera_id_);
+ preview_paused_.store(false);
+}
+
+void Camera::SetMirror(bool mirrored) {
+ if (!videoflip_) {
+ g_info("[camera_desktop] Camera %d: SetMirror(%s) ignored, videoflip_ is null",
+ camera_id_, mirrored ? "true" : "false");
+ return;
+ }
+ g_info("[camera_desktop] Camera %d: SetMirror(%s)", camera_id_,
+ mirrored ? "true" : "false");
+ // GstVideoFlipMethod: 0 = none (identity), 4 = horizontal-flip
+ g_object_set(videoflip_, "method", mirrored ? 4 : 0, nullptr);
+}
+
+void Camera::Dispose() {
+ g_info("[camera_desktop] Camera %d: disposing", camera_id_);
+ // C-2: use atomic exchange so the check-and-set is race-free. If two threads
+ // somehow call Dispose() concurrently, only one proceeds.
+ CameraState prev = state_.exchange(CameraState::kDisposing);
+ if (prev == CameraState::kDisposed || prev == CameraState::kDisposing) {
+ return;
+ }
+
+ // Cancel pending init if still waiting (main thread → main thread, safe).
+ if (pending_init_call_) {
+ RespondToPendingInit(false, "Camera disposed during initialization");
+ }
+
+ // C-4: null the callback atomically BEFORE stopping the pipeline. This
+ // prevents new FFI callbacks from being registered while we're tearing down,
+ // but does NOT free the buffer yet, that must wait until the pipeline stops.
+ image_stream_callback_.store(nullptr);
+
+ // C-1 FIX: stop the pipeline BEFORE freeing image_stream_buffer_.
+ // gst_element_set_state(NULL) blocks until the GStreamer streaming thread
+ // (which runs OnNewSample and accesses image_stream_buffer_) is fully
+ // stopped. Freeing before this point was a use-after-free.
+ if (pipeline_) {
+ videoflip_ = nullptr;
+ gst_element_set_state(pipeline_, GST_STATE_NULL);
+ if (bus_watch_id_ > 0) {
+ g_source_remove(bus_watch_id_);
+ bus_watch_id_ = 0;
+ }
+ gst_object_unref(pipeline_);
+ pipeline_ = nullptr;
+ appsink_ = nullptr;
+ }
+
+ // Now safe: the GStreamer streaming thread is guaranteed to have exited
+ // OnNewSample and will never access image_stream_buffer_ again.
+ if (image_stream_buffer_) {
+ g_free(image_stream_buffer_);
+ image_stream_buffer_ = nullptr;
+ image_stream_buffer_size_ = 0;
+ }
+
+ // Unregister the texture.
+ if (texture_ && texture_registrar_) {
+ fl_texture_registrar_unregister_texture(
+ texture_registrar_, camera_texture_as_fl_texture(texture_));
+ g_object_unref(texture_);
+ texture_ = nullptr;
+ }
+
+ // Send closing event to Dart.
+ g_autoptr(FlValue) args = fl_value_new_map();
+ fl_value_set_string_take(args, "cameraId",
+ fl_value_new_int(camera_id_));
+ fl_method_channel_invoke_method(method_channel_, "cameraClosing", args,
+ nullptr, nullptr, nullptr);
+
+ state_.store(CameraState::kDisposed);
+ g_info("[camera_desktop] Camera %d: disposed", camera_id_);
+}
diff --git a/plugins/camera_desktop/linux/camera.h b/plugins/camera_desktop/linux/camera.h
new file mode 100644
index 0000000..76529bf
--- /dev/null
+++ b/plugins/camera_desktop/linux/camera.h
@@ -0,0 +1,172 @@
+#ifndef CAMERA_H_
+#define CAMERA_H_
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "camera_texture.h"
+#include "device_enumerator.h"
+#include "record_handler.h"
+
+enum class CameraBackend {
+ kV4L2, // Traditional /dev/video* + v4l2src
+ kPipeWire, // Portal-authorized pipewiresrc
+};
+
+enum class CameraState {
+ kCreated,
+ kInitializing,
+ kRunning,
+ kPaused,
+ kDisposing,
+ kDisposed,
+};
+
+// Alias for the image-stream callback function pointer type.
+using ImageStreamCallback = void (*)(int32_t);
+
+struct CameraConfig {
+ std::string device_path;
+ int resolution_preset;
+ bool enable_audio;
+ int target_width;
+ int target_height;
+ int target_fps;
+ int target_bitrate;
+ int audio_bitrate = 0;
+ CameraBackend backend = CameraBackend::kV4L2;
+ int pw_fd = -1; // PipeWire remote fd (only used when backend == kPipeWire)
+};
+
+class Camera {
+ public:
+ Camera(int camera_id,
+ FlTextureRegistrar* texture_registrar,
+ FlMethodChannel* method_channel,
+ const CameraConfig& config);
+ ~Camera();
+
+ int camera_id() const { return camera_id_; }
+ int64_t texture_id() const { return texture_id_; }
+ CameraState state() const { return state_; }
+
+ // Allocates the texture and registers it. Must be called before Initialize.
+ // Returns the texture_id on success, -1 on failure.
+ int64_t RegisterTexture();
+
+ // Builds and starts the GStreamer pipeline. Responds to |method_call|
+ // asynchronously once the first frame arrives or an error/timeout occurs.
+ void Initialize(FlMethodCall* method_call);
+
+ // Captures a still image and saves it to a temporary JPEG file.
+ // Responds to |method_call| with the file path or an error.
+ void TakePicture(FlMethodCall* method_call);
+
+ // Pauses/resumes the live preview.
+ void PausePreview();
+ void ResumePreview();
+
+ // Starts video recording (silent, no audio).
+ void StartVideoRecording(FlMethodCall* method_call);
+
+ // Stops video recording and returns the file path.
+ void StopVideoRecording(FlMethodCall* method_call);
+
+ // Starts/stops sending raw frame data to Dart via method channel.
+ void StartImageStream();
+ void StopImageStream();
+
+ // FFI image stream access.
+ void* GetImageStreamBuffer() const { return image_stream_buffer_; }
+ void RegisterImageStreamCallback(void (*callback)(int32_t));
+ void UnregisterImageStreamCallback();
+
+ // Toggles horizontal mirroring on the live video feed.
+ void SetMirror(bool mirrored);
+
+ // Tears down the pipeline and releases all resources.
+ void Dispose();
+
+ private:
+ bool BuildPipeline(GError** error);
+ void RespondToPendingInit(bool success, const char* error_message);
+
+ // GStreamer callbacks (static with user_data = Camera*).
+ static GstFlowReturn OnNewSample(GstAppSink* sink, gpointer user_data);
+ static gboolean OnBusMessage(GstBus* bus, GstMessage* msg,
+ gpointer user_data);
+ static gboolean OnInitTimeout(gpointer user_data);
+
+ // Sends an error event to Dart via the method channel.
+ void SendError(const std::string& description);
+
+ int camera_id_;
+ int64_t texture_id_;
+ // state_ is written from the GStreamer streaming thread (OnNewSample) and
+ // read/written from the main thread. Must be atomic. (C-2)
+ std::atomic state_;
+ CameraConfig config_;
+
+ FlTextureRegistrar* texture_registrar_; // Not owned.
+ FlMethodChannel* method_channel_; // Not owned.
+ CameraTexture* texture_; // Owned (GObject ref).
+
+ GstElement* pipeline_;
+ GstElement* tee_; // For branching preview + recording.
+ GstElement* appsink_;
+ GstElement* videoflip_; // Named element in pipeline for mirror toggle.
+ guint bus_watch_id_;
+ guint init_timeout_id_;
+
+ std::unique_ptr record_handler_;
+
+ // Pending async initialization, stores the FlMethodCall until first frame.
+ // Only accessed from the main thread (set in Initialize, cleared in
+ // RespondToPendingInit which is always dispatched via g_idle_add to main).
+ FlMethodCall* pending_init_call_;
+
+ // Written from the GStreamer streaming thread; read from main thread. (C-2)
+ std::atomic first_frame_received_;
+
+ // Read from GStreamer streaming thread, written from main thread. (C-3)
+ std::atomic preview_paused_;
+
+ std::atomic image_streaming_;
+
+ // FFI image stream shared buffer.
+ // NOTE: The |ready| field acts as a release/acquire flag between the
+ // GStreamer thread (writer) and Dart (reader). The native side MUST issue a
+ // std::atomic_thread_fence(release) before writing ready=1, ensuring all
+ // pixel writes are visible before Dart observes ready==1. (C-5)
+ struct ImageStreamBuffer {
+ int64_t sequence;
+ int32_t width;
+ int32_t height;
+ int32_t bytes_per_row;
+ int32_t format; // 0=BGRA, 1=RGBA
+ int32_t ready; // 1=Dart may read, 0=native writing
+ int32_t _pad;
+ uint8_t pixels[]; // flexible array member
+ };
+
+ ImageStreamBuffer* image_stream_buffer_ = nullptr;
+ size_t image_stream_buffer_size_ = 0;
+
+ // Written from the main thread, read from the GStreamer streaming thread.
+ // Must be atomic to avoid data races and torn reads. (C-4)
+ std::atomic image_stream_callback_{nullptr};
+
+ int64_t image_stream_sequence_ = 0;
+
+ // Written from the GStreamer streaming thread on first frame, read from the
+ // main thread in StartVideoRecording. Must be atomic. (H-2)
+ std::atomic actual_width_;
+ std::atomic actual_height_;
+};
+
+#endif // CAMERA_H_
diff --git a/plugins/camera_desktop/linux/camera_desktop_plugin.cc b/plugins/camera_desktop/linux/camera_desktop_plugin.cc
new file mode 100644
index 0000000..db58e01
--- /dev/null
+++ b/plugins/camera_desktop/linux/camera_desktop_plugin.cc
@@ -0,0 +1,467 @@
+#include "include/camera_desktop/camera_desktop_plugin.h"
+
+#include