Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.uvccamera.flutter;
import android.os.Handler;
import android.os.Looper;
import com.serenegiant.usb.IFrameCallback;
import com.serenegiant.usb.UVCCamera;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import io.flutter.plugin.common.EventChannel;
/**
* A callback that receives frames from the UVC camera and continuously
* sends them to Flutter via EventChannel.
*/
public class UvcCameraImageStreamFrameCallback implements IFrameCallback {
/// Event sink to send data back to Flutter
private final EventChannel.EventSink eventSink;

/// Handler to ensure events are sent on the main thread
private final Handler mainHandler = new Handler(Looper.getMainLooper());

/// Frame width and height
private final int width;
private final int height;
/**
* Constructor
*
* @param sink EventSink to send frame data
* @param width Width of the preview frame
* @param height Height of the preview frame
*/
public UvcCameraImageStreamFrameCallback(EventChannel.EventSink sink, int width, int height) {
this.eventSink = sink;
this.width = width;
this.height = height;
}
@Override
public void onFrame(final ByteBuffer frame) {
if (eventSink == null) return;
// Create a byte array with the remaining size of the ByteBuffer
final byte[] bytes = new byte[frame.remaining()];

// Copy the NV21 frame data into our byte array
frame.get(bytes);
// Package the frame data with its metadata
final Map<String, Object> frameData = new HashMap<>();
frameData.put("bytes", bytes);
frameData.put("width", width);
frameData.put("height", height);
// Explicitly indicate that the format is NV21
frameData.put("format", UVCCamera.PIXEL_FORMAT_NV21);
// MethodChannel and EventChannel require events to be fired on the UI (main) thread.
// Post the success event back to the main looper.
mainHandler.post(new Runnable() {
@Override
public void run() {
eventSink.success(frameData);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,52 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result

result.success(null);
}
case "startImageStream": {
// Retrieve the camera ID requested by the Dart side
final var cameraId = (int) call.argument("cameraId");
Log.d(TAG, "MethodCall: startImageStream cameraId=" + cameraId);
try {
// [IMPORTANT] Establish an EventChannel to stream real-time frames.
// Channel naming convention: "uvccamera/camera@{cameraId}/image_stream"
final EventChannel imageStreamChannel = new EventChannel(
binaryMessenger,
"uvccamera/camera@" + cameraId + "/image_stream"
);
// A StreamHandler acts as the bridge. Native code starts listening to
// the camera only when the Dart side actively subscribes to this channel.
imageStreamChannel.setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
Log.d(TAG, "ImageStreamChannel: onListen! Dart side subscribed.");
// The pipe (events) is ready. Flick the switch on the 1st floor!
platform.startImageStream(cameraId, events);
}
@Override
public void onCancel(Object arguments) {
Log.d(TAG, "ImageStreamChannel: onCancel! Dart side unsubscribed.");
// No one is listening anymore, shut down the switch.
platform.stopImageStream(cameraId);
}
});
// Acknowledge the Dart side that the channel is readied.
result.success(null);
} catch (final Exception e) {
Log.e(TAG, "Failed to initialize image stream channel", e);
result.error("STREAM_ERROR", e.getMessage(), null);
}
break;
}

case "stopImageStream": {
// Handles explicit stop requests from the Dart side
// (independent of EventChannel's onCancel).
final var cameraId = (int) call.argument("cameraId");
Log.d(TAG, "MethodCall: stopImageStream cameraId=" + cameraId);
platform.stopImageStream(cameraId);
result.success(null);
break;
}

default -> result.notImplemented();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,62 @@ public List<Size> getSupportedSizes(final int cameraId) {
return UVCCamera.getSupportedSize(-1, cameraResources.camera().getSupportedSize());
}

/**
* Starts the continuous image stream for the specified camera.
* Registers our custom frame callback to receive NV21 frames.
*
* @param cameraId The camera ID
* @param sink the event sink to push frame data to Flutter
*/

public void startImageStream(final int cameraId, final EventChannel.EventSink sink) {
Log.v(TAG, "startImageStream: cameraId=" + cameraId);
// Find the requested camera from our active resources map
final var cameraResources = camerasResources.get(cameraId);
if (cameraResources == null) {
throw new IllegalArgumentException("Camera resources not found: " + cameraId);
}
final var camera = cameraResources.camera();
// Retrieve current preview size to calculate the buffer size
final var size = camera.getPreviewSize();
if (size == null) {
throw new IllegalStateException("Camera preview size is not set");
}
try {
// Bind our custom stream callback to the camera hardware event loop,
// explicitly requesting PIXEL_FORMAT_NV21 for immediate ML analysis.
camera.setFrameCallback(
new UvcCameraImageStreamFrameCallback(sink, size.width, size.height),
UVCCamera.PIXEL_FORMAT_NV21
);
Log.d(TAG, "startImageStream: frame callback set successfully");
} catch (final Exception e) {
Log.e(TAG, "startImageStream: failed to set frame callback", e);
sink.error("CAMERA_ERROR", "Failed to start image stream: " + e.getMessage(), null);
}
}

/**
* Stops the continuous image stream for the specified camera.
* @param cameraId The camera ID
*/
public void stopImageStream(final int cameraId) {
Log.v(TAG, "stopImageStream: cameraId=" + cameraId);
final var cameraResources = camerasResources.get(cameraId);
if (cameraResources == null) {
return; // Already closed or doesn't exist, safe to ignore
}
try {
// Unregister the frame callback by passing null
cameraResources.camera().setFrameCallback(null, 0);
Log.d(TAG, "stopImageStream: frame callback unset successfully");
} catch (final Exception e) {
Log.e(TAG, "stopImageStream: failed to unset frame callback", e);
}
}



/**
* Gets the preview size for the specified camera
*
Expand Down
32 changes: 16 additions & 16 deletions flutter/example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
file:
dependency: transitive
description:
Expand Down Expand Up @@ -123,26 +123,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
Expand Down Expand Up @@ -171,10 +171,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
path:
dependency: transitive
description:
Expand Down Expand Up @@ -312,10 +312,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.7"
uvccamera:
dependency: "direct main"
description:
Expand All @@ -327,10 +327,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
Expand All @@ -356,5 +356,5 @@ packages:
source: hosted
version: "3.0.4"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.2"
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.24.0"
47 changes: 46 additions & 1 deletion flutter/lib/src/uvccamera_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ class UvcCameraController extends ValueNotifier<UvcCameraControllerState> {
/// Stream of camera button events.
Stream<UvcCameraButtonEvent>? _cameraButtonEventStream;

/// Subscription to the continuous image stream.
StreamSubscription<Map<dynamic, dynamic>>? _imageStreamSubscription;

/// Creates a new [UvcCameraController] object.
UvcCameraController({required this.device, this.resolutionPreset = UvcCameraResolutionPreset.max})
: super(UvcCameraControllerState.uninitialized(device));
: super(UvcCameraControllerState.uninitialized(device));

/// Initializes the controller on the device.
Future<void> initialize() => _initialize(device);
Expand Down Expand Up @@ -114,6 +117,9 @@ class UvcCameraController extends ValueNotifier<UvcCameraControllerState> {
_cameraErrorEventStream = null;
}

if (_imageStreamSubscription != null) {
await stopImageStream();
}
_textureId = null;

if (_cameraId != null) {
Expand Down Expand Up @@ -213,6 +219,45 @@ class UvcCameraController extends ValueNotifier<UvcCameraControllerState> {
}
}

/// Starts the continuous image stream.
///
/// The [onAvailable] callback will be invoked for every frame captured
/// by the camera. Each frame is a [Map] containing:
/// - 'bytes': Raw NV21 pixel data (Uint8List)
/// - 'width': Frame width (int)
/// - 'height': Frame height (int)
/// - 'format': Pixel format constant (int)
Future<void> startImageStream(void Function(Map<dynamic, dynamic> image) onAvailable) async {
_ensureInitializedNotDisposed();
// Prevent duplicate subscriptions
if (_imageStreamSubscription != null) {
throw UvcCameraControllerIllegalStateException('Image stream is already running');
}
// Request the stream pipe from the platform layer
final stream = await UvcCameraPlatformInterface.instance.startImageStream(_cameraId!);

// Subscribe and forward each frame event to the caller's callback
_imageStreamSubscription = stream.listen((event) {
onAvailable(event);
});
}

/// Stops the continuous image stream.
Future<void> stopImageStream() async {
// Silently return if already stopped (safe to call multiple times)
if (_imageStreamSubscription == null) {
return;
}
// Cancel the Dart-side subscription first
await _imageStreamSubscription?.cancel();
_imageStreamSubscription = null;

// Then notify the native side to release the frame callback
if (_cameraId != null) {
await UvcCameraPlatformInterface.instance.stopImageStream(_cameraId!);
}
}

/// Returns a widget showing a live camera preview.
Widget buildPreview() {
_ensureInitializedNotDisposed();
Expand Down
31 changes: 31 additions & 0 deletions flutter/lib/src/uvccamera_platform.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class UvcCameraPlatform extends UvcCameraPlatformInterface {
final Map<int, EventChannel> _buttonEventChannels = {};
final Map<int, Stream<UvcCameraButtonEvent>> _buttonEventStreams = {};

// --- [New] Cache for image stream EventChannels and Streams
final Map<int, EventChannel> _imageStreamEventChannels = {};
final Map<int, Stream<Map<dynamic, dynamic>>> _imageStreamStreams = {};

@override
Future<bool> isSupported() async {
final result = await _nativeMethodChannel.invokeMethod<bool>('isSupported');
Expand Down Expand Up @@ -217,6 +221,33 @@ class UvcCameraPlatform extends UvcCameraPlatformInterface {
await _nativeMethodChannel.invokeMethod<void>('stopVideoRecording', {'cameraId': cameraId});
}

@override
Future<Stream<Map<dynamic, dynamic>>> startImageStream(int cameraId) async {
// 1. Invoke native method to prepare the camera and FrameCallback
await _nativeMethodChannel.invokeMethod<void>('startImageStream', {'cameraId': cameraId});
// 2. Establish the EventChannel using the agreed naming convention
final imageStreamEventChannel = EventChannel('uvccamera/camera@$cameraId/image_stream');

// 3. Receive the broadcast stream and cast the events to the expected Map format
final imageStreamStream = imageStreamEventChannel.receiveBroadcastStream().map((event) {
return event as Map<dynamic, dynamic>;
});
// 4. Cache the channel and stream for later cleanup
_imageStreamEventChannels[cameraId] = imageStreamEventChannel;
_imageStreamStreams[cameraId] = imageStreamStream;
// 5. Return the stream pipe directly to the controller
return imageStreamStream;
}

@override
Future<void> stopImageStream(int cameraId) async {
// 1. Instruct the native side to stop the image stream
await _nativeMethodChannel.invokeMethod<void>('stopImageStream', {'cameraId': cameraId});
// 2. Clear cached references
_imageStreamEventChannels.remove(cameraId);
_imageStreamStreams.remove(cameraId);
}

@override
Stream<UvcCameraDeviceEvent> get deviceEventStream {
return _deviceEventStream ??= _deviceEventChannel.receiveBroadcastStream().map((event) {
Expand Down
Loading