add plugin camera desktop

This commit is contained in:
kyo
2026-06-22 09:39:31 +07:00
parent 92daeef0c5
commit 5407afbd6e
144 changed files with 15900 additions and 1 deletions

View File

@@ -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';

View File

@@ -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<Map<String, bool>> getPlatformCapabilities() async {
try {
final raw = await _channel.invokeMapMethod<String, dynamic>(
'getPlatformCapabilities',
);
if (raw == null) return const <String, bool>{};
final out = <String, bool>{};
raw.forEach((key, value) {
if (value is bool) {
out[key] = value;
}
});
return out;
} on MissingPluginException {
return const <String, bool>{};
} on PlatformException {
return const <String, bool>{};
}
}
/// 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<int, int> _textureIds = {};
/// Broadcast stream for all camera events, filtered by cameraId downstream.
final StreamController<CameraEvent> _eventStreamController =
StreamController<CameraEvent>.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<int, StreamController<CameraImageData>> _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<int, _ActiveImageStream> _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<dynamic> _handleNativeCall(MethodCall call) async {
final args = call.arguments as Map<Object?, Object?>?;
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<CameraEvent> _cameraEvents(int cameraId) => _eventStreamController
.stream
.where((CameraEvent e) => e.cameraId == cameraId);
@override
Future<List<CameraDescription>> availableCameras() async {
final result = await _channel.invokeListMethod<Map<dynamic, dynamic>>(
'availableCameras',
);
if (result == null) return <CameraDescription>[];
return result.map((Map<dynamic, dynamic> m) {
return CameraDescription(
name: m['name'] as String,
lensDirection: CameraLensDirection.values[m['lensDirection'] as int],
sensorOrientation: m['sensorOrientation'] as int,
);
}).toList();
}
@override
Future<int> 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<int> 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<String, dynamic>('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<void> initializeCamera(
int cameraId, {
ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown,
}) async {
try {
final result = await _channel.invokeMapMethod<String, dynamic>(
'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<void> 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<void>('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<CameraInitializedEvent> onCameraInitialized(int cameraId) =>
_cameraEvents(cameraId).whereType<CameraInitializedEvent>();
@override
Stream<CameraResolutionChangedEvent> onCameraResolutionChanged(
int cameraId,
) => _cameraEvents(cameraId).whereType<CameraResolutionChangedEvent>();
@override
Stream<CameraClosingEvent> onCameraClosing(int cameraId) =>
_cameraEvents(cameraId).whereType<CameraClosingEvent>();
@override
Stream<CameraErrorEvent> onCameraError(int cameraId) =>
_cameraEvents(cameraId).whereType<CameraErrorEvent>();
@override
Stream<VideoRecordedEvent> onVideoRecordedEvent(int cameraId) =>
_cameraEvents(cameraId).whereType<VideoRecordedEvent>();
@override
Stream<DeviceOrientationChangedEvent> onDeviceOrientationChanged() =>
Stream<DeviceOrientationChangedEvent>.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<void> pausePreview(int cameraId) async {
try {
await _channel.invokeMethod<void>('pausePreview', {'cameraId': cameraId});
} on PlatformException catch (e) {
throw CameraException(e.code, e.message);
}
}
@override
Future<void> resumePreview(int cameraId) async {
try {
await _channel.invokeMethod<void>('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<void> setMirror(int cameraId, bool mirrored) async {
try {
await _channel.invokeMethod<void>('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<CameraImageData> onStreamedFrameAvailable(
int cameraId, {
CameraImageStreamOptions? options,
}) {
int extractStreamHandle(dynamic value) {
if (value is int) return value;
if (value is Map<dynamic, dynamic>) {
final dynamic raw = value['streamHandle'];
if (raw is int) return raw;
}
return cameraId;
}
ImageStreamPoller? ffi;
int streamHandle = cameraId;
late final StreamController<CameraImageData> controller;
controller = StreamController<CameraImageData>(
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<dynamic>(
'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<void>('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<XFile> takePicture(int cameraId) async {
try {
final path = await _channel.invokeMethod<String>('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<void> prepareForVideoRecording() async {}
@override
Future<void> 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<void> startVideoRecording(
int cameraId, {
Duration? maxVideoDuration,
}) async {
try {
await _channel.invokeMethod<void>('startVideoRecording', {
'cameraId': cameraId,
if (maxVideoDuration != null)
'maxVideoDuration': maxVideoDuration.inMilliseconds,
});
} on PlatformException catch (e) {
throw CameraException(e.code, e.message);
}
}
@override
Future<XFile> stopVideoRecording(int cameraId) async {
try {
final dynamic value = await _channel.invokeMethod<dynamic>(
'stopVideoRecording',
{'cameraId': cameraId},
);
if (value is String) {
return XFile(value);
}
final map = value as Map<dynamic, dynamic>;
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<void> pauseVideoRecording(int cameraId) async {
throw CameraException(
'pauseVideoRecording',
'Pausing video recording is not supported on desktop.',
);
}
@override
Future<void> 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<void> 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<void> setExposureMode(int cameraId, ExposureMode mode) async {
if (mode == ExposureMode.auto) return;
throw CameraException(
'setExposureMode',
'Exposure mode control is not supported on desktop.',
);
}
@override
Future<void> setExposurePoint(int cameraId, Point<double>? point) async {
throw CameraException(
'setExposurePoint',
'Exposure point is not supported on desktop.',
);
}
@override
Future<double> getMinExposureOffset(int cameraId) async => 0.0;
@override
Future<double> getMaxExposureOffset(int cameraId) async => 0.0;
@override
Future<double> getExposureOffsetStepSize(int cameraId) async => 0.0;
@override
Future<double> setExposureOffset(int cameraId, double offset) async => 0.0;
/// No-op for [FocusMode.auto] (the default); throws otherwise.
@override
Future<void> setFocusMode(int cameraId, FocusMode mode) async {
if (mode == FocusMode.auto) return;
throw CameraException(
'setFocusMode',
'Focus mode control is not supported on desktop.',
);
}
@override
Future<void> setFocusPoint(int cameraId, Point<double>? point) async {
throw CameraException(
'setFocusPoint',
'Focus point is not supported on desktop.',
);
}
@override
Future<double> getMinZoomLevel(int cameraId) async => 1.0;
@override
Future<double> getMaxZoomLevel(int cameraId) async => 1.0;
@override
Future<void> 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<void> lockCaptureOrientation(
int cameraId,
DeviceOrientation orientation,
) async {}
/// No-op on desktop, orientation locking is not applicable.
@override
Future<void> unlockCaptureOrientation(int cameraId) async {}
@override
Future<void> 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<CameraImageData> 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;
}

View File

@@ -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() {}
}

View File

@@ -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) {}
}

View File

@@ -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<Void> Function(Int64 streamHandle);
/// Dart-side function type for [_GetBufferNative].
typedef _GetBufferDart = Pointer<Void> Function(int streamHandle);
/// Native function signature for registering a frame-ready callback.
typedef _RegisterCallbackNative =
Void Function(
Int64 streamHandle,
Pointer<NativeFunction<Void Function(Int32)>> callback,
);
/// Dart-side function type for [_RegisterCallbackNative].
typedef _RegisterCallbackDart =
void Function(
int streamHandle,
Pointer<NativeFunction<Void Function(Int32)>> 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<CameraImageData> 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<NativeFunction<Void Function(Int32)>> _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<CameraImageData>? _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<NativeFunction<Void Function(Int32)>>(
'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<CameraImageData> 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<ImageStreamBuffer>().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<Uint8>() + sizeOf<ImageStreamBuffer>();
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;
}
}