add plugin camera desktop
This commit is contained in:
9
plugins/camera_desktop/lib/camera_desktop.dart
Normal file
9
plugins/camera_desktop/lib/camera_desktop.dart
Normal 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';
|
||||
685
plugins/camera_desktop/lib/src/camera_desktop_plugin.dart
Normal file
685
plugins/camera_desktop/lib/src/camera_desktop_plugin.dart
Normal 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;
|
||||
}
|
||||
18
plugins/camera_desktop/lib/src/camera_desktop_stub.dart
Normal file
18
plugins/camera_desktop/lib/src/camera_desktop_stub.dart
Normal 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() {}
|
||||
}
|
||||
5
plugins/camera_desktop/lib/src/camera_desktop_web.dart
Normal file
5
plugins/camera_desktop/lib/src/camera_desktop_web.dart
Normal 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) {}
|
||||
}
|
||||
292
plugins/camera_desktop/lib/src/image_stream_ffi.dart
Normal file
292
plugins/camera_desktop/lib/src/image_stream_ffi.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user