diff --git a/packages/camera/camera_web/example/integration_test/camera_test.dart b/packages/camera/camera_web/example/integration_test/camera_test.dart index 0f1dcf7049d9..6eeed23ecf56 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; @@ -28,13 +29,7 @@ void main() { navigator = MockNavigator(); mediaDevices = MockMediaDevices(); - final videoElement = VideoElement() - ..src = - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' - ..preload = 'true' - ..width = 10 - ..height = 10; - + final videoElement = getVideoElementWithBlankStream(Size(10, 10)); mediaStream = videoElement.captureStream(); when(() => window.navigator).thenReturn(navigator); @@ -469,6 +464,49 @@ void main() { }); }); + group('getVideoSize', () { + testWidgets( + 'returns a size ' + 'based on the first video track settings', (tester) async { + const videoSize = Size(1280, 720); + + final videoElement = getVideoElementWithBlankStream(videoSize); + mediaStream = videoElement.captureStream(); + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect( + await camera.getVideoSize(), + equals(videoSize), + ); + }); + + testWidgets( + 'returns Size.zero ' + 'if the camera is missing video tracks', (tester) async { + // Create a video stream with no video tracks. + final videoElement = VideoElement(); + mediaStream = videoElement.captureStream(); + + final camera = Camera( + textureId: 1, + window: window, + ); + + await camera.initialize(); + + expect( + await camera.getVideoSize(), + equals(Size.zero), + ); + }); + }); + group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index eef17ecfdff9..d5e1835391ad 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -5,6 +5,7 @@ import 'dart:html'; import 'dart:ui'; +import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/camera_web.dart'; import 'package:camera_web/src/camera.dart'; @@ -33,13 +34,8 @@ void main() { window = MockWindow(); navigator = MockNavigator(); mediaDevices = MockMediaDevices(); - videoElement = VideoElement() - ..src = - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4' - ..preload = 'true' - ..width = 10 - ..height = 10 - ..crossOrigin = 'anonymous'; + + videoElement = getVideoElementWithBlankStream(Size(10, 10)); cameraSettings = MockCameraSettings(); @@ -327,21 +323,18 @@ void main() { const ultraHighResolutionSize = Size(3840, 2160); const maxResolutionSize = Size(3840, 2160); - late CameraDescription cameraDescription; - late CameraMetadata cameraMetadata; - - setUp(() { - cameraDescription = CameraDescription( - name: 'name', - lensDirection: CameraLensDirection.front, - sensorOrientation: 0, - ); + final cameraDescription = CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ); - cameraMetadata = CameraMetadata( - deviceId: 'deviceId', - facingMode: 'user', - ); + final cameraMetadata = CameraMetadata( + deviceId: 'deviceId', + facingMode: 'user', + ); + setUp(() { // Add metadata for the camera description. (CameraPlatform.instance as CameraPlugin) .camerasMetadata[cameraDescription] = cameraMetadata; @@ -434,11 +427,38 @@ void main() { }); }); - testWidgets('initializeCamera throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.initializeCamera(cameraId), - throwsUnimplementedError, - ); + group('initializeCamera', () { + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.initializeCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + + testWidgets('initializes and plays the camera', (tester) async { + final camera = MockCamera(); + + when(camera.getVideoSize).thenAnswer((_) => Future.value(Size(10, 10))); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + + verify(camera.initialize).called(1); + verify(camera.play).called(1); + }); }); testWidgets('lockCaptureOrientation throws UnimplementedError', @@ -628,13 +648,78 @@ void main() { ); }); + group('getCamera', () { + testWidgets('returns the correct camera', (tester) async { + final camera = Camera(textureId: cameraId, window: window); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + (CameraPlatform.instance as CameraPlugin).getCamera(cameraId), + equals(camera), + ); + }); + + testWidgets( + 'throws CameraException ' + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => (CameraPlatform.instance as CameraPlugin).getCamera(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCodes.notFound, + ), + ), + ); + }); + }); + group('events', () { - testWidgets('onCameraInitialized throws UnimplementedError', - (tester) async { + testWidgets( + 'onCameraInitialized emits a CameraInitializedEvent ' + 'on initializeCamera', (tester) async { + // Mock the camera to use a blank video stream of size 1280x720. + const videoSize = Size(1280, 720); + + videoElement = getVideoElementWithBlankStream(videoSize); + + when( + () => mediaDevices.getUserMedia(any()), + ).thenAnswer((_) async => videoElement.captureStream()); + + final camera = Camera( + textureId: cameraId, + window: window, + ); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final Stream eventStream = + CameraPlatform.instance.onCameraInitialized(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + expect( - () => CameraPlatform.instance.onCameraInitialized(cameraId), - throwsUnimplementedError, + await streamQueue.next, + CameraInitializedEvent( + cameraId, + videoSize.width, + videoSize.height, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), ); + + await streamQueue.cancel(); }); testWidgets('onCameraResolutionChanged throws UnimplementedError', diff --git a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart index 3702aee8e184..fa627ca0b7e6 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -3,7 +3,9 @@ // found in the LICENSE file. import 'dart:html'; +import 'dart:ui'; +import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_settings.dart'; import 'package:mocktail/mocktail.dart'; @@ -17,6 +19,8 @@ class MockCameraSettings extends Mock implements CameraSettings {} class MockMediaStreamTrack extends Mock implements MediaStreamTrack {} +class MockCamera extends Mock implements Camera {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); @@ -54,3 +58,22 @@ class FakeDomException extends Fake implements DomException { @override String get name => _name; } + +/// Returns a video element with a blank stream of size [videoSize]. +/// +/// Can be used to mock a video stream: +/// ```dart +/// final videoElement = getVideoElementWithBlankStream(Size(100, 100)); +/// final videoStream = videoElement.captureStream(); +/// ``` +VideoElement getVideoElementWithBlankStream(Size videoSize) { + final canvasElement = CanvasElement( + width: videoSize.width.toInt(), + height: videoSize.height.toInt(), + )..context2D.fillRect(0, 0, videoSize.width, videoSize.height); + + final videoElement = VideoElement() + ..srcObject = canvasElement.captureStream(); + + return videoElement; +} diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 41692d548882..334f117be274 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:html' as html; +import 'dart:ui'; import 'shims/dart_ui.dart' as ui; import 'package:camera_platform_interface/camera_platform_interface.dart'; @@ -171,6 +172,30 @@ class Camera { return XFile(html.Url.createObjectUrl(blob)); } + /// Returns a size of the camera video based on its first video track size. + /// + /// Returns [Size.zero] if the camera is missing a video track or + /// the video track does not include the width or height setting. + Future getVideoSize() async { + final videoTracks = videoElement.srcObject?.getVideoTracks() ?? []; + + if (videoTracks.isEmpty) { + return Size.zero; + } + + final defaultVideoTrack = videoTracks.first; + final defaultVideoTrackSettings = defaultVideoTrack.getSettings(); + + final width = defaultVideoTrackSettings['width']; + final height = defaultVideoTrackSettings['height']; + + if (width != null && height != null) { + return Size(width, height); + } else { + return Size.zero; + } + } + /// Disposes the camera by stopping the camera stream /// and reloading the camera source. void dispose() { diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 80ab13d37d13..e58572e50ee4 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -13,6 +13,7 @@ import 'package:camera_web/src/types/types.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:stream_transform/stream_transform.dart'; /// The web implementation of [CameraPlatform]. /// @@ -42,6 +43,18 @@ class CameraPlugin extends CameraPlatform { @visibleForTesting final camerasMetadata = {}; + /// The controller used to broadcast different camera events. + /// + /// It is `broadcast` as multiple controllers may subscribe + /// to different stream views of this controller. + @visibleForTesting + final cameraEventStreamController = StreamController.broadcast(); + + /// Returns a stream of camera events for the given [cameraId]. + Stream _cameraEvents(int cameraId) => + cameraEventStreamController.stream + .where((event) => event.cameraId == cameraId); + /// The current browser window used to access media devices. @visibleForTesting html.Window? window = html.window; @@ -186,14 +199,34 @@ class CameraPlugin extends CameraPlatform { @override Future initializeCamera( int cameraId, { + // The image format group is currently not supported. ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, - }) { - throw UnimplementedError('initializeCamera() is not implemented.'); + }) async { + final camera = getCamera(cameraId); + + await camera.initialize(); + await camera.play(); + + final cameraSize = await camera.getVideoSize(); + + cameraEventStreamController.add( + CameraInitializedEvent( + cameraId, + cameraSize.width, + cameraSize.height, + // TODO(camera_web): Add support for exposure mode and point (https://github.com/flutter/flutter/issues/86857). + ExposureMode.auto, + false, + // TODO(camera_web): Add support for focus mode and point (https://github.com/flutter/flutter/issues/86858). + FocusMode.auto, + false, + ), + ); } @override Stream onCameraInitialized(int cameraId) { - throw UnimplementedError('onCameraInitialized() is not implemented.'); + return _cameraEvents(cameraId).whereType(); } @override @@ -348,4 +381,21 @@ class CameraPlugin extends CameraPlatform { return mediaDevices.getUserMedia(cameraOptions.toJson()); } + + /// Returns a camera for the given [cameraId]. + /// + /// Throws a [CameraException] if the camera does not exist. + @visibleForTesting + Camera getCamera(int cameraId) { + final camera = cameras[cameraId]; + + if (camera == null) { + throw CameraException( + CameraErrorCodes.notFound, + 'No camera found for the given camera id $cameraId.', + ); + } + + return camera; + } }