| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:html'; |
| import 'dart:ui'; |
| |
| import 'package:async/async.dart'; |
| import 'package:camera_platform_interface/camera_platform_interface.dart'; |
| import 'package:camera_web/src/camera.dart'; |
| import 'package:camera_web/src/camera_service.dart'; |
| import 'package:camera_web/src/types/types.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| import 'package:integration_test/integration_test.dart'; |
| import 'package:mocktail/mocktail.dart'; |
| |
| import 'helpers/helpers.dart'; |
| |
| void main() { |
| IntegrationTestWidgetsFlutterBinding.ensureInitialized(); |
| |
| group('Camera', () { |
| const int textureId = 1; |
| |
| late Window window; |
| late Navigator navigator; |
| late MediaDevices mediaDevices; |
| |
| late MediaStream mediaStream; |
| late CameraService cameraService; |
| |
| setUp(() { |
| window = MockWindow(); |
| navigator = MockNavigator(); |
| mediaDevices = MockMediaDevices(); |
| |
| when(() => window.navigator).thenReturn(navigator); |
| when(() => navigator.mediaDevices).thenReturn(mediaDevices); |
| |
| cameraService = MockCameraService(); |
| |
| final VideoElement videoElement = |
| getVideoElementWithBlankStream(const Size(10, 10)); |
| mediaStream = videoElement.captureStream(); |
| |
| when( |
| () => cameraService.getMediaStreamForOptions( |
| any(), |
| cameraId: any(named: 'cameraId'), |
| ), |
| ).thenAnswer((_) => Future<MediaStream>.value(mediaStream)); |
| }); |
| |
| setUpAll(() { |
| registerFallbackValue(MockCameraOptions()); |
| }); |
| |
| group('initialize', () { |
| testWidgets( |
| 'calls CameraService.getMediaStreamForOptions ' |
| 'with provided options', (WidgetTester tester) async { |
| final CameraOptions options = CameraOptions( |
| video: VideoConstraints( |
| facingMode: FacingModeConstraint.exact(CameraType.user), |
| width: const VideoSizeConstraint(ideal: 200), |
| ), |
| ); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| options: options, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| verify( |
| () => cameraService.getMediaStreamForOptions( |
| options, |
| cameraId: textureId, |
| ), |
| ).called(1); |
| }); |
| |
| testWidgets( |
| 'creates a video element ' |
| 'with correct properties', (WidgetTester tester) async { |
| const AudioConstraints audioConstraints = |
| AudioConstraints(enabled: true); |
| final VideoConstraints videoConstraints = VideoConstraints( |
| facingMode: FacingModeConstraint( |
| CameraType.user, |
| ), |
| ); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| options: CameraOptions( |
| audio: audioConstraints, |
| video: videoConstraints, |
| ), |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect(camera.videoElement, isNotNull); |
| expect(camera.videoElement.autoplay, isFalse); |
| expect(camera.videoElement.muted, isTrue); |
| expect(camera.videoElement.srcObject, mediaStream); |
| expect(camera.videoElement.attributes.keys, contains('playsinline')); |
| |
| expect( |
| camera.videoElement.style.transformOrigin, equals('center center')); |
| expect(camera.videoElement.style.pointerEvents, equals('none')); |
| expect(camera.videoElement.style.width, equals('100%')); |
| expect(camera.videoElement.style.height, equals('100%')); |
| expect(camera.videoElement.style.objectFit, equals('cover')); |
| }); |
| |
| testWidgets( |
| 'flips the video element horizontally ' |
| 'for a back camera', (WidgetTester tester) async { |
| final VideoConstraints videoConstraints = VideoConstraints( |
| facingMode: FacingModeConstraint( |
| CameraType.environment, |
| ), |
| ); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| options: CameraOptions( |
| video: videoConstraints, |
| ), |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect(camera.videoElement.style.transform, equals('scaleX(-1)')); |
| }); |
| |
| testWidgets( |
| 'creates a wrapping div element ' |
| 'with correct properties', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect(camera.divElement, isNotNull); |
| expect(camera.divElement.style.objectFit, equals('cover')); |
| expect(camera.divElement.children, contains(camera.videoElement)); |
| }); |
| |
| testWidgets('initializes the camera stream', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect(camera.stream, mediaStream); |
| }); |
| |
| testWidgets( |
| 'throws an exception ' |
| 'when CameraService.getMediaStreamForOptions throws', |
| (WidgetTester tester) async { |
| final Exception exception = |
| Exception('A media stream exception occured.'); |
| |
| when(() => cameraService.getMediaStreamForOptions(any(), |
| cameraId: any(named: 'cameraId'))).thenThrow(exception); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| expect( |
| camera.initialize, |
| throwsA(exception), |
| ); |
| }); |
| }); |
| |
| group('play', () { |
| testWidgets('starts playing the video element', |
| (WidgetTester tester) async { |
| bool startedPlaying = false; |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| final StreamSubscription<Event> cameraPlaySubscription = camera |
| .videoElement.onPlay |
| .listen((Event event) => startedPlaying = true); |
| |
| await camera.play(); |
| |
| expect(startedPlaying, isTrue); |
| |
| await cameraPlaySubscription.cancel(); |
| }); |
| |
| testWidgets( |
| 'initializes the camera stream ' |
| 'from CameraService.getMediaStreamForOptions ' |
| 'if it does not exist', (WidgetTester tester) async { |
| const CameraOptions options = CameraOptions( |
| video: VideoConstraints( |
| width: VideoSizeConstraint(ideal: 100), |
| ), |
| ); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| options: options, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| /// Remove the video element's source |
| /// by stopping the camera. |
| camera.stop(); |
| |
| await camera.play(); |
| |
| // Should be called twice: for initialize and play. |
| verify( |
| () => cameraService.getMediaStreamForOptions( |
| options, |
| cameraId: textureId, |
| ), |
| ).called(2); |
| |
| expect(camera.videoElement.srcObject, mediaStream); |
| expect(camera.stream, mediaStream); |
| }); |
| }); |
| |
| group('pause', () { |
| testWidgets('pauses the camera stream', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| expect(camera.videoElement.paused, isFalse); |
| |
| camera.pause(); |
| |
| expect(camera.videoElement.paused, isTrue); |
| }); |
| }); |
| |
| group('stop', () { |
| testWidgets('resets the camera stream', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| camera.stop(); |
| |
| expect(camera.videoElement.srcObject, isNull); |
| expect(camera.stream, isNull); |
| }); |
| }); |
| |
| group('takePicture', () { |
| testWidgets('returns a captured picture', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| final XFile pictureFile = await camera.takePicture(); |
| |
| expect(pictureFile, isNotNull); |
| }); |
| |
| group( |
| 'enables the torch mode ' |
| 'when taking a picture', () { |
| late List<MediaStreamTrack> videoTracks; |
| late MediaStream videoStream; |
| late VideoElement videoElement; |
| |
| setUp(() { |
| videoTracks = <MediaStreamTrack>[ |
| MockMediaStreamTrack(), |
| MockMediaStreamTrack() |
| ]; |
| videoStream = FakeMediaStream(videoTracks); |
| |
| videoElement = getVideoElementWithBlankStream(const Size(100, 100)) |
| ..muted = true; |
| |
| when(() => videoTracks.first.applyConstraints(any())) |
| .thenAnswer((_) async => <dynamic, dynamic>{}); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| }); |
| |
| testWidgets('if the flash mode is auto', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream |
| ..videoElement = videoElement |
| ..flashMode = FlashMode.auto; |
| |
| await camera.play(); |
| |
| final XFile _ = await camera.takePicture(); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': true, |
| } |
| ] |
| }), |
| ).called(1); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': false, |
| } |
| ] |
| }), |
| ).called(1); |
| }); |
| |
| testWidgets('if the flash mode is always', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream |
| ..videoElement = videoElement |
| ..flashMode = FlashMode.always; |
| |
| await camera.play(); |
| |
| final XFile _ = await camera.takePicture(); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': true, |
| } |
| ] |
| }), |
| ).called(1); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': false, |
| } |
| ] |
| }), |
| ).called(1); |
| }); |
| }); |
| }); |
| |
| group('getVideoSize', () { |
| testWidgets( |
| 'returns a size ' |
| 'based on the first video track settings', |
| (WidgetTester tester) async { |
| const Size videoSize = Size(1280, 720); |
| |
| final VideoElement videoElement = |
| getVideoElementWithBlankStream(videoSize); |
| mediaStream = videoElement.captureStream(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect( |
| camera.getVideoSize(), |
| equals(videoSize), |
| ); |
| }); |
| |
| testWidgets( |
| 'returns Size.zero ' |
| 'if the camera is missing video tracks', (WidgetTester tester) async { |
| // Create a video stream with no video tracks. |
| final VideoElement videoElement = VideoElement(); |
| mediaStream = videoElement.captureStream(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect( |
| camera.getVideoSize(), |
| equals(Size.zero), |
| ); |
| }); |
| }); |
| |
| group('setFlashMode', () { |
| late List<MediaStreamTrack> videoTracks; |
| late MediaStream videoStream; |
| |
| setUp(() { |
| videoTracks = <MediaStreamTrack>[ |
| MockMediaStreamTrack(), |
| MockMediaStreamTrack() |
| ]; |
| videoStream = FakeMediaStream(videoTracks); |
| |
| when(() => videoTracks.first.applyConstraints(any())) |
| .thenAnswer((_) async => <dynamic, dynamic>{}); |
| |
| when(videoTracks.first.getCapabilities) |
| .thenReturn(<dynamic, dynamic>{}); |
| }); |
| |
| testWidgets('sets the camera flash mode', (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| const FlashMode flashMode = FlashMode.always; |
| |
| camera.setFlashMode(flashMode); |
| |
| expect( |
| camera.flashMode, |
| equals(flashMode), |
| ); |
| }); |
| |
| testWidgets( |
| 'enables the torch mode ' |
| 'if the flash mode is torch', (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| camera.setFlashMode(FlashMode.torch); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': true, |
| } |
| ] |
| }), |
| ).called(1); |
| }); |
| |
| testWidgets( |
| 'disables the torch mode ' |
| 'if the flash mode is not torch', (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| camera.setFlashMode(FlashMode.auto); |
| |
| verify( |
| () => videoTracks.first.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| 'torch': false, |
| } |
| ] |
| }), |
| ).called(1); |
| }); |
| |
| group('throws a CameraWebException', () { |
| testWidgets( |
| 'with torchModeNotSupported error ' |
| 'when there are no media devices', (WidgetTester tester) async { |
| when(() => navigator.mediaDevices).thenReturn(null); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| expect( |
| () => camera.setFlashMode(FlashMode.always), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.torchModeNotSupported, |
| ), |
| ), |
| ); |
| }); |
| |
| testWidgets( |
| 'with torchModeNotSupported error ' |
| 'when the torch mode is not supported ' |
| 'in the browser', (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': false, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| expect( |
| () => camera.setFlashMode(FlashMode.always), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.torchModeNotSupported, |
| ), |
| ), |
| ); |
| }); |
| |
| testWidgets( |
| 'with torchModeNotSupported error ' |
| 'when the torch mode is not supported ' |
| 'by the camera', (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': false, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ) |
| ..window = window |
| ..stream = videoStream; |
| |
| expect( |
| () => camera.setFlashMode(FlashMode.always), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.torchModeNotSupported, |
| ), |
| ), |
| ); |
| }); |
| |
| testWidgets( |
| 'with notStarted error ' |
| 'when the camera stream has not been initialized', |
| (WidgetTester tester) async { |
| when(mediaDevices.getSupportedConstraints) |
| .thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| when(videoTracks.first.getCapabilities).thenReturn(<dynamic, dynamic>{ |
| 'torch': true, |
| }); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| )..window = window; |
| |
| expect( |
| () => camera.setFlashMode(FlashMode.always), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.notStarted, |
| ), |
| ), |
| ); |
| }); |
| }); |
| }); |
| |
| group('zoomLevel', () { |
| group('getMaxZoomLevel', () { |
| testWidgets( |
| 'returns maximum ' |
| 'from CameraService.getZoomLevelCapabilityForCamera', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( |
| minimum: 50.0, |
| maximum: 100.0, |
| videoTrack: MockMediaStreamTrack(), |
| ); |
| |
| when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .thenReturn(zoomLevelCapability); |
| |
| final double maximumZoomLevel = camera.getMaxZoomLevel(); |
| |
| verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .called(1); |
| |
| expect( |
| maximumZoomLevel, |
| equals(zoomLevelCapability.maximum), |
| ); |
| }); |
| }); |
| |
| group('getMinZoomLevel', () { |
| testWidgets( |
| 'returns minimum ' |
| 'from CameraService.getZoomLevelCapabilityForCamera', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( |
| minimum: 50.0, |
| maximum: 100.0, |
| videoTrack: MockMediaStreamTrack(), |
| ); |
| |
| when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .thenReturn(zoomLevelCapability); |
| |
| final double minimumZoomLevel = camera.getMinZoomLevel(); |
| |
| verify(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .called(1); |
| |
| expect( |
| minimumZoomLevel, |
| equals(zoomLevelCapability.minimum), |
| ); |
| }); |
| }); |
| |
| group('setZoomLevel', () { |
| testWidgets( |
| 'applies zoom on the video track ' |
| 'from CameraService.getZoomLevelCapabilityForCamera', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final MockMediaStreamTrack videoTrack = MockMediaStreamTrack(); |
| |
| final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( |
| minimum: 50.0, |
| maximum: 100.0, |
| videoTrack: videoTrack, |
| ); |
| |
| when(() => videoTrack.applyConstraints(any())) |
| .thenAnswer((_) async {}); |
| |
| when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .thenReturn(zoomLevelCapability); |
| |
| const double zoom = 75.0; |
| |
| camera.setZoomLevel(zoom); |
| |
| verify( |
| () => videoTrack.applyConstraints(<dynamic, dynamic>{ |
| 'advanced': <dynamic>[ |
| <dynamic, dynamic>{ |
| ZoomLevelCapability.constraintName: zoom, |
| } |
| ] |
| }), |
| ).called(1); |
| }); |
| |
| group('throws a CameraWebException', () { |
| testWidgets( |
| 'with zoomLevelInvalid error ' |
| 'when the provided zoom level is below minimum', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( |
| minimum: 50.0, |
| maximum: 100.0, |
| videoTrack: MockMediaStreamTrack(), |
| ); |
| |
| when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .thenReturn(zoomLevelCapability); |
| |
| expect( |
| () => camera.setZoomLevel(45.0), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.zoomLevelInvalid, |
| ), |
| )); |
| }); |
| |
| testWidgets( |
| 'with zoomLevelInvalid error ' |
| 'when the provided zoom level is below minimum', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final ZoomLevelCapability zoomLevelCapability = ZoomLevelCapability( |
| minimum: 50.0, |
| maximum: 100.0, |
| videoTrack: MockMediaStreamTrack(), |
| ); |
| |
| when(() => cameraService.getZoomLevelCapabilityForCamera(camera)) |
| .thenReturn(zoomLevelCapability); |
| |
| expect( |
| () => camera.setZoomLevel(105.0), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.zoomLevelInvalid, |
| ), |
| ), |
| ); |
| }); |
| }); |
| }); |
| }); |
| |
| group('getLensDirection', () { |
| testWidgets( |
| 'returns a lens direction ' |
| 'based on the first video track settings', |
| (WidgetTester tester) async { |
| final MockVideoElement videoElement = MockVideoElement(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| )..videoElement = videoElement; |
| |
| final MockMediaStreamTrack firstVideoTrack = MockMediaStreamTrack(); |
| |
| when(() => videoElement.srcObject).thenReturn( |
| FakeMediaStream(<MediaStreamTrack>[ |
| firstVideoTrack, |
| MockMediaStreamTrack(), |
| ]), |
| ); |
| |
| when(firstVideoTrack.getSettings) |
| .thenReturn(<dynamic, dynamic>{'facingMode': 'environment'}); |
| |
| when(() => cameraService.mapFacingModeToLensDirection('environment')) |
| .thenReturn(CameraLensDirection.external); |
| |
| expect( |
| camera.getLensDirection(), |
| equals(CameraLensDirection.external), |
| ); |
| }); |
| |
| testWidgets( |
| 'returns null ' |
| 'if the first video track is missing the facing mode', |
| (WidgetTester tester) async { |
| final MockVideoElement videoElement = MockVideoElement(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| )..videoElement = videoElement; |
| |
| final MockMediaStreamTrack firstVideoTrack = MockMediaStreamTrack(); |
| |
| when(() => videoElement.srcObject).thenReturn( |
| FakeMediaStream(<MediaStreamTrack>[ |
| firstVideoTrack, |
| MockMediaStreamTrack(), |
| ]), |
| ); |
| |
| when(firstVideoTrack.getSettings).thenReturn(<dynamic, dynamic>{}); |
| |
| expect( |
| camera.getLensDirection(), |
| isNull, |
| ); |
| }); |
| |
| testWidgets( |
| 'returns null ' |
| 'if the camera is missing video tracks', (WidgetTester tester) async { |
| // Create a video stream with no video tracks. |
| final VideoElement videoElement = VideoElement(); |
| mediaStream = videoElement.captureStream(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect( |
| camera.getLensDirection(), |
| isNull, |
| ); |
| }); |
| }); |
| |
| group('getViewType', () { |
| testWidgets('returns a correct view type', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| |
| expect( |
| camera.getViewType(), |
| equals('plugins.flutter.io/camera_$textureId'), |
| ); |
| }); |
| }); |
| |
| group('video recording', () { |
| const String supportedVideoType = 'video/webm'; |
| |
| late MediaRecorder mediaRecorder; |
| |
| bool isVideoTypeSupported(String type) => type == supportedVideoType; |
| |
| setUp(() { |
| mediaRecorder = MockMediaRecorder(); |
| |
| when(() => mediaRecorder.onError) |
| .thenAnswer((_) => const Stream<Event>.empty()); |
| }); |
| |
| group('startVideoRecording', () { |
| testWidgets( |
| 'creates a media recorder ' |
| 'with appropriate options', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| )..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| expect( |
| camera.mediaRecorder!.stream, |
| equals(camera.stream), |
| ); |
| |
| expect( |
| camera.mediaRecorder!.mimeType, |
| equals(supportedVideoType), |
| ); |
| |
| expect( |
| camera.mediaRecorder!.state, |
| equals('recording'), |
| ); |
| }); |
| |
| testWidgets('listens to the media recorder data events', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| verify( |
| () => mediaRecorder.addEventListener('dataavailable', any()), |
| ).called(1); |
| }); |
| |
| testWidgets('listens to the media recorder stop events', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| verify( |
| () => mediaRecorder.addEventListener('stop', any()), |
| ).called(1); |
| }); |
| |
| testWidgets('starts a video recording', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| verify(mediaRecorder.start).called(1); |
| }); |
| |
| testWidgets( |
| 'starts a video recording ' |
| 'with maxVideoDuration', (WidgetTester tester) async { |
| const Duration maxVideoDuration = Duration(hours: 1); |
| |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); |
| |
| verify(() => mediaRecorder.start(maxVideoDuration.inMilliseconds)) |
| .called(1); |
| }); |
| |
| group('throws a CameraWebException', () { |
| testWidgets( |
| 'with notSupported error ' |
| 'when maxVideoDuration is 0 milliseconds or less', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| expect( |
| () => camera.startVideoRecording(maxVideoDuration: Duration.zero), |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.notSupported, |
| ), |
| ), |
| ); |
| }); |
| |
| testWidgets( |
| 'with notSupported error ' |
| 'when no video types are supported', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| )..isVideoTypeSupported = (String type) => false; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| expect( |
| camera.startVideoRecording, |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.notSupported, |
| ), |
| ), |
| ); |
| }); |
| }); |
| }); |
| |
| group('pauseVideoRecording', () { |
| testWidgets('pauses a video recording', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| )..mediaRecorder = mediaRecorder; |
| |
| await camera.pauseVideoRecording(); |
| |
| verify(mediaRecorder.pause).called(1); |
| }); |
| |
| testWidgets( |
| 'throws a CameraWebException ' |
| 'with videoRecordingNotStarted error ' |
| 'if the video recording was not started', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ); |
| |
| expect( |
| camera.pauseVideoRecording, |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.videoRecordingNotStarted, |
| ), |
| ), |
| ); |
| }); |
| }); |
| |
| group('resumeVideoRecording', () { |
| testWidgets('resumes a video recording', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| )..mediaRecorder = mediaRecorder; |
| |
| await camera.resumeVideoRecording(); |
| |
| verify(mediaRecorder.resume).called(1); |
| }); |
| |
| testWidgets( |
| 'throws a CameraWebException ' |
| 'with videoRecordingNotStarted error ' |
| 'if the video recording was not started', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ); |
| |
| expect( |
| camera.resumeVideoRecording, |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.videoRecordingNotStarted, |
| ), |
| ), |
| ); |
| }); |
| }); |
| |
| group('stopVideoRecording', () { |
| testWidgets( |
| 'stops a video recording and ' |
| 'returns the captured file ' |
| 'based on all video data parts', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| late void Function(Event) videoDataAvailableListener; |
| late void Function(Event) videoRecordingStoppedListener; |
| |
| when( |
| () => mediaRecorder.addEventListener('dataavailable', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoDataAvailableListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| |
| when( |
| () => mediaRecorder.addEventListener('stop', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoRecordingStoppedListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| |
| Blob? finalVideo; |
| List<Blob>? videoParts; |
| camera.blobBuilder = (List<Blob> blobs, String videoType) { |
| videoParts = <Blob>[...blobs]; |
| finalVideo = Blob(blobs, videoType); |
| return finalVideo!; |
| }; |
| |
| await camera.startVideoRecording(); |
| final Future<XFile> videoFileFuture = camera.stopVideoRecording(); |
| |
| final Blob capturedVideoPartOne = Blob(<Object>[]); |
| final Blob capturedVideoPartTwo = Blob(<Object>[]); |
| |
| final List<Blob> capturedVideoParts = <Blob>[ |
| capturedVideoPartOne, |
| capturedVideoPartTwo, |
| ]; |
| |
| videoDataAvailableListener(FakeBlobEvent(capturedVideoPartOne)); |
| videoDataAvailableListener(FakeBlobEvent(capturedVideoPartTwo)); |
| |
| videoRecordingStoppedListener(Event('stop')); |
| |
| final XFile videoFile = await videoFileFuture; |
| |
| verify(mediaRecorder.stop).called(1); |
| |
| expect( |
| videoFile, |
| isNotNull, |
| ); |
| |
| expect( |
| videoFile.mimeType, |
| equals(supportedVideoType), |
| ); |
| |
| expect( |
| videoFile.name, |
| equals(finalVideo.hashCode.toString()), |
| ); |
| |
| expect( |
| videoParts, |
| equals(capturedVideoParts), |
| ); |
| }); |
| |
| testWidgets( |
| 'throws a CameraWebException ' |
| 'with videoRecordingNotStarted error ' |
| 'if the video recording was not started', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ); |
| |
| expect( |
| camera.stopVideoRecording, |
| throwsA( |
| isA<CameraWebException>() |
| .having( |
| (CameraWebException e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (CameraWebException e) => e.code, |
| 'code', |
| CameraErrorCode.videoRecordingNotStarted, |
| ), |
| ), |
| ); |
| }); |
| }); |
| |
| group('on video data available', () { |
| late void Function(Event) videoDataAvailableListener; |
| |
| setUp(() { |
| when( |
| () => mediaRecorder.addEventListener('dataavailable', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoDataAvailableListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| }); |
| |
| testWidgets( |
| 'stops a video recording ' |
| 'if maxVideoDuration is given and ' |
| 'the recording was not stopped manually', |
| (WidgetTester tester) async { |
| const Duration maxVideoDuration = Duration(hours: 1); |
| |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); |
| |
| when(() => mediaRecorder.state).thenReturn('recording'); |
| |
| videoDataAvailableListener(FakeBlobEvent(Blob(<Object>[]))); |
| |
| await Future<void>.microtask(() {}); |
| |
| verify(mediaRecorder.stop).called(1); |
| }); |
| }); |
| |
| group('on video recording stopped', () { |
| late void Function(Event) videoRecordingStoppedListener; |
| |
| setUp(() { |
| when( |
| () => mediaRecorder.addEventListener('stop', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoRecordingStoppedListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| }); |
| |
| testWidgets('stops listening to the media recorder data events', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| videoRecordingStoppedListener(Event('stop')); |
| |
| await Future<void>.microtask(() {}); |
| |
| verify( |
| () => mediaRecorder.removeEventListener('dataavailable', any()), |
| ).called(1); |
| }); |
| |
| testWidgets('stops listening to the media recorder stop events', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| videoRecordingStoppedListener(Event('stop')); |
| |
| await Future<void>.microtask(() {}); |
| |
| verify( |
| () => mediaRecorder.removeEventListener('stop', any()), |
| ).called(1); |
| }); |
| |
| testWidgets('stops listening to the media recorder errors', |
| (WidgetTester tester) async { |
| final StreamController<ErrorEvent> onErrorStreamController = |
| StreamController<ErrorEvent>(); |
| |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = isVideoTypeSupported; |
| |
| when(() => mediaRecorder.onError) |
| .thenAnswer((_) => onErrorStreamController.stream); |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| videoRecordingStoppedListener(Event('stop')); |
| |
| await Future<void>.microtask(() {}); |
| |
| expect( |
| onErrorStreamController.hasListener, |
| isFalse, |
| ); |
| }); |
| }); |
| }); |
| |
| group('dispose', () { |
| testWidgets("resets the video element's source", |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.dispose(); |
| |
| expect(camera.videoElement.srcObject, isNull); |
| }); |
| |
| testWidgets('closes the onEnded stream', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.dispose(); |
| |
| expect( |
| camera.onEndedController.isClosed, |
| isTrue, |
| ); |
| }); |
| |
| testWidgets('closes the onVideoRecordedEvent stream', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.dispose(); |
| |
| expect( |
| camera.videoRecorderController.isClosed, |
| isTrue, |
| ); |
| }); |
| |
| testWidgets('closes the onVideoRecordingError stream', |
| (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| await camera.initialize(); |
| await camera.dispose(); |
| |
| expect( |
| camera.videoRecordingErrorController.isClosed, |
| isTrue, |
| ); |
| }); |
| }); |
| |
| group('events', () { |
| group('onVideoRecordedEvent', () { |
| testWidgets( |
| 'emits a VideoRecordedEvent ' |
| 'when a video recording is created', (WidgetTester tester) async { |
| const Duration maxVideoDuration = Duration(hours: 1); |
| const String supportedVideoType = 'video/webm'; |
| |
| final MockMediaRecorder mediaRecorder = MockMediaRecorder(); |
| when(() => mediaRecorder.onError) |
| .thenAnswer((_) => const Stream<Event>.empty()); |
| |
| final Camera camera = Camera( |
| textureId: 1, |
| cameraService: cameraService, |
| ) |
| ..mediaRecorder = mediaRecorder |
| ..isVideoTypeSupported = (String type) => type == 'video/webm'; |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| late void Function(Event) videoDataAvailableListener; |
| late void Function(Event) videoRecordingStoppedListener; |
| |
| when( |
| () => mediaRecorder.addEventListener('dataavailable', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoDataAvailableListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| |
| when( |
| () => mediaRecorder.addEventListener('stop', any()), |
| ).thenAnswer((Invocation invocation) { |
| videoRecordingStoppedListener = |
| invocation.positionalArguments[1] as void Function(Event); |
| }); |
| |
| final StreamQueue<VideoRecordedEvent> streamQueue = |
| StreamQueue<VideoRecordedEvent>(camera.onVideoRecordedEvent); |
| |
| await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); |
| |
| Blob? finalVideo; |
| camera.blobBuilder = (List<Blob> blobs, String videoType) { |
| finalVideo = Blob(blobs, videoType); |
| return finalVideo!; |
| }; |
| |
| videoDataAvailableListener(FakeBlobEvent(Blob(<Object>[]))); |
| videoRecordingStoppedListener(Event('stop')); |
| |
| expect( |
| await streamQueue.next, |
| equals( |
| isA<VideoRecordedEvent>() |
| .having( |
| (VideoRecordedEvent e) => e.cameraId, |
| 'cameraId', |
| textureId, |
| ) |
| .having( |
| (VideoRecordedEvent e) => e.file, |
| 'file', |
| isA<XFile>() |
| .having( |
| (XFile f) => f.mimeType, |
| 'mimeType', |
| supportedVideoType, |
| ) |
| .having( |
| (XFile f) => f.name, |
| 'name', |
| finalVideo.hashCode.toString(), |
| ), |
| ) |
| .having( |
| (VideoRecordedEvent e) => e.maxVideoDuration, |
| 'maxVideoDuration', |
| maxVideoDuration, |
| ), |
| ), |
| ); |
| |
| await streamQueue.cancel(); |
| }); |
| }); |
| |
| group('onEnded', () { |
| testWidgets( |
| 'emits the default video track ' |
| 'when it emits an ended event', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final StreamQueue<MediaStreamTrack> streamQueue = |
| StreamQueue<MediaStreamTrack>(camera.onEnded); |
| |
| await camera.initialize(); |
| |
| final List<MediaStreamTrack> videoTracks = |
| camera.stream!.getVideoTracks(); |
| final MediaStreamTrack defaultVideoTrack = videoTracks.first; |
| |
| defaultVideoTrack.dispatchEvent(Event('ended')); |
| |
| expect( |
| await streamQueue.next, |
| equals(defaultVideoTrack), |
| ); |
| |
| await streamQueue.cancel(); |
| }); |
| |
| testWidgets( |
| 'emits the default video track ' |
| 'when the camera is stopped', (WidgetTester tester) async { |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| ); |
| |
| final StreamQueue<MediaStreamTrack> streamQueue = |
| StreamQueue<MediaStreamTrack>(camera.onEnded); |
| |
| await camera.initialize(); |
| |
| final List<MediaStreamTrack> videoTracks = |
| camera.stream!.getVideoTracks(); |
| final MediaStreamTrack defaultVideoTrack = videoTracks.first; |
| |
| camera.stop(); |
| |
| expect( |
| await streamQueue.next, |
| equals(defaultVideoTrack), |
| ); |
| |
| await streamQueue.cancel(); |
| }); |
| }); |
| |
| group('onVideoRecordingError', () { |
| testWidgets( |
| 'emits an ErrorEvent ' |
| 'when the media recorder fails ' |
| 'when recording a video', (WidgetTester tester) async { |
| final MockMediaRecorder mediaRecorder = MockMediaRecorder(); |
| final StreamController<ErrorEvent> errorController = |
| StreamController<ErrorEvent>(); |
| |
| final Camera camera = Camera( |
| textureId: textureId, |
| cameraService: cameraService, |
| )..mediaRecorder = mediaRecorder; |
| |
| when(() => mediaRecorder.onError) |
| .thenAnswer((_) => errorController.stream); |
| |
| final StreamQueue<ErrorEvent> streamQueue = |
| StreamQueue<ErrorEvent>(camera.onVideoRecordingError); |
| |
| await camera.initialize(); |
| await camera.play(); |
| |
| await camera.startVideoRecording(); |
| |
| final ErrorEvent errorEvent = ErrorEvent('type'); |
| errorController.add(errorEvent); |
| |
| expect( |
| await streamQueue.next, |
| equals(errorEvent), |
| ); |
| |
| await streamQueue.cancel(); |
| }); |
| }); |
| }); |
| }); |
| } |