diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart index f21a3b12c81f..8cf1e90e36c1 100644 --- a/packages/camera/camera/lib/src/camera_controller.dart +++ b/packages/camera/camera/lib/src/camera_controller.dart @@ -335,7 +335,7 @@ class CameraController extends ValueNotifier { /// Preparing audio can cause a minor delay in the CameraPreview view on iOS. /// If video recording is intended, calling this early eliminates this delay /// that would otherwise be experienced when video recording is started. - /// This operation is a no-op on Android. + /// This operation is a no-op on Android and Web. /// /// Throws a [CameraException] if the prepare fails. Future prepareForVideoRecording() async { diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 098fe62c3a1f..8596b3595852 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.2.1 +* Add video recording functionality. * Fix cameraNotReadable error that prevented access to the camera on some Android devices. ## 0.2.0 diff --git a/packages/camera/camera_web/README.md b/packages/camera/camera_web/README.md index 032a345fad99..918e695496a4 100644 --- a/packages/camera/camera_web/README.md +++ b/packages/camera/camera_web/README.md @@ -83,11 +83,24 @@ if (kIsWeb) { } ``` +### Video recording + +The video recording implementation is backed by [MediaRecorder Web API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder) with the following [browser support](https://caniuse.com/mdn-api_mediarecorder): + +![Data on support for the MediaRecorder feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/mediarecorder.png). + +A video is recorded in one of the following video MIME types: +- video/webm (e.g. on Chrome or Firefox) +- video/mp4 (e.g. on Safari) + +Pausing, resuming or stopping the video recording throws a `PlatformException` with the `videoRecordingNotStarted` error code if the video recording was not started. + +For the browsers that do not support the video recording: +- `CameraPlatform.startVideoRecording` throws a `PlatformException` with the `notSupported` error code. + ## Missing implementation The web implementation of [`camera`][camera] is missing the following features: - -- Video recording ([in progress](https://github.com/flutter/plugins/pull/4210)) - Exposure mode, point and offset - Focus mode and point - Sensor orientation diff --git a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart index d0250c6e4e26..a298b57dfd7f 100644 --- a/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_error_code_test.dart @@ -113,6 +113,13 @@ void main() { ); }); + testWidgets('videoRecordingNotStarted', (tester) async { + expect( + CameraErrorCode.videoRecordingNotStarted.toString(), + equals('videoRecordingNotStarted'), + ); + }); + testWidgets('unknown', (tester) async { expect( CameraErrorCode.unknown.toString(), 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 712d8c77ff3e..3a25e33c5398 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -2,6 +2,7 @@ // 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'; @@ -531,7 +532,7 @@ void main() { ).called(1); }); - group('throws CameraWebException', () { + group('throws a CameraWebException', () { testWidgets( 'with torchModeNotSupported error ' 'when there are no media devices', (tester) async { @@ -774,7 +775,7 @@ void main() { ).called(1); }); - group('throws CameraWebException', () { + group('throws a CameraWebException', () { testWidgets( 'with zoomLevelInvalid error ' 'when the provided zoom level is below minimum', (tester) async { @@ -827,20 +828,21 @@ void main() { .thenReturn(zoomLevelCapability); expect( - () => camera.setZoomLevel(105.0), - throwsA( - isA() - .having( - (e) => e.cameraId, - 'cameraId', - textureId, - ) - .having( - (e) => e.code, - 'code', - CameraErrorCode.zoomLevelInvalid, - ), - )); + () => camera.setZoomLevel(105.0), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.zoomLevelInvalid, + ), + ), + ); }); }); }); @@ -943,6 +945,503 @@ void main() { }); }); + group('video recording', () { + const supportedVideoType = 'video/webm'; + + late MediaRecorder mediaRecorder; + + bool isVideoTypeSupported(String type) => type == supportedVideoType; + + setUp(() { + mediaRecorder = MockMediaRecorder(); + + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + }); + + group('startVideoRecording', () { + testWidgets( + 'creates a media recorder ' + 'with appropriate options', (tester) async { + final 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', + (tester) async { + final 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', + (tester) async { + final 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', (tester) async { + final 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', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final 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', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + expect( + () => camera.startVideoRecording(maxVideoDuration: Duration.zero), + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + + testWidgets( + 'with notSupported error ' + 'when no video types are supported', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + )..isVideoTypeSupported = (type) => false; + + await camera.initialize(); + await camera.play(); + + expect( + camera.startVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.notSupported, + ), + ), + ); + }); + }); + }); + + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final 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', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.pauseVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final 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', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.resumeVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('stopVideoRecording', () { + testWidgets( + 'stops a video recording and ' + 'returns the captured file ' + 'based on all video data parts', (tester) async { + final 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) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + Blob? finalVideo; + List? videoParts; + camera.blobBuilder = (blobs, videoType) { + videoParts = [...blobs]; + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + await camera.startVideoRecording(); + final videoFileFuture = camera.stopVideoRecording(); + + final capturedVideoPartOne = Blob([]); + final capturedVideoPartTwo = Blob([]); + + final capturedVideoParts = [ + capturedVideoPartOne, + capturedVideoPartTwo, + ]; + + videoDataAvailableListener + ..call(FakeBlobEvent(capturedVideoPartOne)) + ..call(FakeBlobEvent(capturedVideoPartTwo)); + + videoRecordingStoppedListener.call(Event('stop')); + + final 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', (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ); + + expect( + camera.stopVideoRecording, + throwsA( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.code, + 'code', + CameraErrorCode.videoRecordingNotStarted, + ), + ), + ); + }); + }); + + group('on video data available', () { + late void Function(Event) videoDataAvailableListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('dataavailable', any()), + ).thenAnswer((invocation) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets( + 'stops a video recording ' + 'if maxVideoDuration is given and ' + 'the recording was not stopped manually', (tester) async { + const maxVideoDuration = Duration(hours: 1); + + final 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.call(FakeBlobEvent(Blob([]))); + + await Future.microtask(() {}); + + verify(mediaRecorder.stop).called(1); + }); + }); + + group('on video recording stopped', () { + late void Function(Event) videoRecordingStoppedListener; + + setUp(() { + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + }); + + testWidgets('stops listening to the media recorder data events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('dataavailable', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder stop events', + (tester) async { + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + videoRecordingStoppedListener.call(Event('stop')); + + await Future.microtask(() {}); + + verify( + () => mediaRecorder.removeEventListener('stop', any()), + ).called(1); + }); + + testWidgets('stops listening to the media recorder errors', + (tester) async { + final onErrorStreamController = StreamController(); + + final 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.call(Event('stop')); + + await Future.microtask(() {}); + + expect( + onErrorStreamController.hasListener, + isFalse, + ); + }); + }); + }); + group('dispose', () { testWidgets('resets the video element\'s source', (tester) async { final camera = Camera( @@ -951,14 +1450,143 @@ void main() { ); await camera.initialize(); - await camera.dispose(); expect(camera.videoElement.srcObject, isNull); }); + + testWidgets('closes the onEnded stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.onEndedController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordedEvent stream', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.videoRecorderController.isClosed, + isTrue, + ); + }); + + testWidgets('closes the onVideoRecordingError stream', (tester) async { + final 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', (tester) async { + const maxVideoDuration = Duration(hours: 1); + const supportedVideoType = 'video/webm'; + + final mediaRecorder = MockMediaRecorder(); + when(() => mediaRecorder.onError) + .thenAnswer((_) => const Stream.empty()); + + final camera = Camera( + textureId: 1, + cameraService: cameraService, + ) + ..mediaRecorder = mediaRecorder + ..isVideoTypeSupported = (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) { + videoDataAvailableListener = invocation.positionalArguments[1]; + }); + + when( + () => mediaRecorder.addEventListener('stop', any()), + ).thenAnswer((invocation) { + videoRecordingStoppedListener = invocation.positionalArguments[1]; + }); + + final streamQueue = StreamQueue(camera.onVideoRecordedEvent); + + await camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + + Blob? finalVideo; + camera.blobBuilder = (blobs, videoType) { + finalVideo = Blob(blobs, videoType); + return finalVideo!; + }; + + videoDataAvailableListener.call(FakeBlobEvent(Blob([]))); + videoRecordingStoppedListener.call(Event('stop')); + + expect( + await streamQueue.next, + equals( + isA() + .having( + (e) => e.cameraId, + 'cameraId', + textureId, + ) + .having( + (e) => e.file, + 'file', + isA() + .having( + (f) => f.mimeType, + 'mimeType', + supportedVideoType, + ) + .having( + (f) => f.name, + 'name', + finalVideo.hashCode.toString(), + ), + ) + .having( + (e) => e.maxVideoDuration, + 'maxVideoDuration', + maxVideoDuration, + ), + ), + ); + + await streamQueue.cancel(); + }); + }); + group('onEnded', () { testWidgets( 'emits the default video track ' @@ -1009,22 +1637,40 @@ void main() { await streamQueue.cancel(); }); + }); + group('onVideoRecordingError', () { testWidgets( - 'no longer emits the default video track ' - 'when the camera is disposed', (tester) async { + 'emits an ErrorEvent ' + 'when the media recorder fails ' + 'when recording a video', (tester) async { + final mediaRecorder = MockMediaRecorder(); + final errorController = StreamController(); + final camera = Camera( textureId: textureId, cameraService: cameraService, - ); + )..mediaRecorder = mediaRecorder; + + when(() => mediaRecorder.onError) + .thenAnswer((_) => errorController.stream); + + final streamQueue = StreamQueue(camera.onVideoRecordingError); await camera.initialize(); - await camera.dispose(); + await camera.play(); + + await camera.startVideoRecording(); + + final errorEvent = ErrorEvent('type'); + errorController.add(errorEvent); expect( - camera.onEndedStreamController.isClosed, - isTrue, + await streamQueue.next, + equals(errorEvent), ); + + await streamQueue.cancel(); }); }); }); 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 f469f3c30849..9749559ed8c6 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 @@ -1019,43 +1019,377 @@ void main() { }); }); - testWidgets('prepareForVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.prepareForVideoRecording(), - throwsUnimplementedError, - ); - }); + group('startVideoRecording', () { + late Camera camera; - testWidgets('startVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.startVideoRecording(cameraId), - throwsUnimplementedError, - ); + setUp(() { + camera = MockCamera(); + + when(camera.startVideoRecording).thenAnswer((_) async {}); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + }); + + testWidgets('starts a video recording', (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + verify(camera.startVideoRecording).called(1); + }); + + testWidgets('listens to the onVideoRecordingError stream', + (tester) async { + final videoRecordingErrorController = StreamController(); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isTrue, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws DomException', + (tester) async { + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when startVideoRecording throws CameraWebException', + (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.startVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('stopVideoRecording throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.stopVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('stopVideoRecording', () { + testWidgets('stops a video recording', (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(capturedVideo)); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final video = + await CameraPlatform.instance.stopVideoRecording(cameraId); + + verify(camera.stopVideoRecording).called(1); + + expect(video, capturedVideo); + }); + + testWidgets('stops listening to the onVideoRecordingError stream', + (tester) async { + final camera = MockCamera(); + final videoRecordingErrorController = StreamController(); + + when(camera.startVideoRecording).thenAnswer((_) async => {}); + + when(camera.stopVideoRecording) + .thenAnswer((_) => Future.value(MockXFile())); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.startVideoRecording(cameraId); + final _ = await CameraPlatform.instance.stopVideoRecording(cameraId); + + expect( + videoRecordingErrorController.hasListener, + isFalse, + ); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when stopVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('pauseVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.pauseVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('pauseVideoRecording', () { + testWidgets('pauses a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.pauseVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.pauseVideoRecording(cameraId); + + verify(camera.pauseVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when pauseVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); - testWidgets('resumeVideoRecording throws UnimplementedError', - (tester) async { - expect( - () => CameraPlatform.instance.resumeVideoRecording(cameraId), - throwsUnimplementedError, - ); + group('resumeVideoRecording', () { + testWidgets('resumes a video recording', (tester) async { + final camera = MockCamera(); + + when(camera.resumeVideoRecording).thenAnswer((_) async {}); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.resumeVideoRecording(cameraId); + + verify(camera.resumeVideoRecording).called(1); + }); + + group('throws PlatformException', () { + testWidgets( + 'with notFound error ' + 'if the camera does not exist', (tester) async { + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + CameraErrorCode.notFound.toString(), + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws DomException', + (tester) async { + final camera = MockCamera(); + final exception = FakeDomException(DomException.INVALID_STATE); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.name, + ), + ), + ); + }); + + testWidgets('when resumeVideoRecording throws CameraWebException', + (tester) async { + final camera = MockCamera(); + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect( + () => CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA().having( + (e) => e.code, + 'code', + exception.code.toString(), + ), + ), + ); + }); + }); }); group('setFlashMode', () { @@ -1676,6 +2010,7 @@ void main() { late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; + late StreamController videoRecordingErrorController; setUp(() { camera = MockCamera(); @@ -1684,6 +2019,7 @@ void main() { errorStreamController = StreamController(); abortStreamController = StreamController(); endedStreamController = StreamController(); + videoRecordingErrorController = StreamController(); when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); @@ -1698,6 +2034,11 @@ void main() { when(() => camera.onEnded) .thenAnswer((_) => endedStreamController.stream); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(camera.startVideoRecording).thenAnswer((_) async {}); }); testWidgets('disposes the correct camera', (tester) async { @@ -1754,6 +2095,18 @@ void main() { expect(endedStreamController.hasListener, isFalse); }); + testWidgets('cancels the camera video recording error subscriptions', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + await CameraPlatform.instance.dispose(cameraId); + + expect(videoRecordingErrorController.hasListener, isFalse); + }); + group('throws PlatformException', () { testWidgets( 'with notFound error ' @@ -1832,6 +2185,7 @@ void main() { late StreamController errorStreamController, abortStreamController; late StreamController endedStreamController; + late StreamController videoRecordingErrorController; setUp(() { camera = MockCamera(); @@ -1840,6 +2194,7 @@ void main() { errorStreamController = StreamController(); abortStreamController = StreamController(); endedStreamController = StreamController(); + videoRecordingErrorController = StreamController(); when(camera.getVideoSize).thenReturn(Size(10, 10)); when(camera.initialize).thenAnswer((_) => Future.value()); @@ -1853,6 +2208,11 @@ void main() { when(() => camera.onEnded) .thenAnswer((_) => endedStreamController.stream); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => videoRecordingErrorController.stream); + + when(() => camera.startVideoRecording()).thenAnswer((_) async => {}); }); testWidgets( @@ -2258,13 +2618,210 @@ void main() { await streamQueue.cancel(); }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on startVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(() => camera.onVideoRecordingError) + .thenAnswer((_) => const Stream.empty()); + + when( + () => camera.startVideoRecording( + maxVideoDuration: any(named: 'maxVideoDuration'), + ), + ).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.startVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on the camera video recording error event', (tester) async { + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.startVideoRecording(cameraId); + + final errorEvent = FakeErrorEvent('type', 'message'); + + videoRecordingErrorController.add(errorEvent); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on stopVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.stopVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.stopVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on pauseVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.pauseVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.pauseVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits a CameraErrorEvent ' + 'on resumeVideoRecording error', (tester) async { + final exception = CameraWebException( + cameraId, + CameraErrorCode.notStarted, + 'description', + ); + + when(camera.resumeVideoRecording).thenThrow(exception); + + final Stream eventStream = + CameraPlatform.instance.onCameraError(cameraId); + + final streamQueue = StreamQueue(eventStream); + + expect( + () async => + await CameraPlatform.instance.resumeVideoRecording(cameraId), + throwsA( + isA(), + ), + ); + + expect( + await streamQueue.next, + equals( + CameraErrorEvent( + cameraId, + 'Error code: ${exception.code}, error message: ${exception.description}', + ), + ), + ); + + await streamQueue.cancel(); + }); }); - testWidgets('onVideoRecordedEvent throws UnimplementedError', + testWidgets('onVideoRecordedEvent emits a VideoRecordedEvent', (tester) async { + final camera = MockCamera(); + final capturedVideo = MockXFile(); + final stream = Stream.value( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero)); + when(() => camera.onVideoRecordedEvent).thenAnswer((_) => stream); + + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + final streamQueue = + StreamQueue(CameraPlatform.instance.onVideoRecordedEvent(cameraId)); + expect( - () => CameraPlatform.instance.onVideoRecordedEvent(cameraId), - throwsUnimplementedError, + await streamQueue.next, + equals( + VideoRecordedEvent(cameraId, capturedVideo, Duration.zero), + ), ); }); 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 e6a11cc0b454..77e9077356f7 100644 --- a/packages/camera/camera_web/example/integration_test/helpers/mocks.dart +++ b/packages/camera/camera_web/example/integration_test/helpers/mocks.dart @@ -41,6 +41,8 @@ class MockXFile extends Mock implements XFile {} class MockJsUtil extends Mock implements JsUtil {} +class MockMediaRecorder extends Mock implements MediaRecorder {} + /// A fake [MediaStream] that returns the provided [_videoTracks]. class FakeMediaStream extends Fake implements MediaStream { FakeMediaStream(this._videoTracks); @@ -122,6 +124,34 @@ class FakeElementStream extends Fake } } +/// A fake [BlobEvent] that returns the provided blob [data]. +class FakeBlobEvent extends Fake implements BlobEvent { + FakeBlobEvent(this._blob); + + final Blob? _blob; + + @override + Blob? get data => _blob; +} + +/// A fake [DomException] that returns the provided error [_name] and [_message]. +class FakeErrorEvent extends Fake implements ErrorEvent { + FakeErrorEvent( + String type, [ + String? message, + ]) : _type = type, + _message = message; + + final String _type; + final String? _message; + + @override + String get type => _type; + + @override + String? get message => _message; +} + /// Returns a video element with a blank stream of size [videoSize]. /// /// Can be used to mock a video stream: diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index 4b7a185b90f7..cf0187057188 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -26,8 +26,10 @@ String _getViewType(int cameraId) => 'plugins.flutter.io/camera_$cameraId'; /// the video element in [_applyDefaultVideoStyles]. /// See: https://github.com/flutter/flutter/issues/79519 /// -/// The camera can be played/stopped by calling [play]/[stop] -/// or may capture a picture by calling [takePicture]. +/// The camera stream can be played/stopped by calling [play]/[stop], +/// may capture a picture by calling [takePicture] or capture a video +/// by calling [startVideoRecording], [pauseVideoRecording], +/// [resumeVideoRecording] or [stopVideoRecording]. /// /// The camera zoom may be adjusted with [setZoomLevel]. The provided /// zoom level must be a value in the range of [getMinZoomLevel] to @@ -76,15 +78,31 @@ class Camera { /// /// MediaStreamTrack.onended: /// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended - Stream get onEnded => onEndedStreamController.stream; + Stream get onEnded => onEndedController.stream; /// The stream controller for the [onEnded] stream. @visibleForTesting - final onEndedStreamController = - StreamController.broadcast(); + final onEndedController = StreamController.broadcast(); StreamSubscription? _onEndedSubscription; + /// The stream of the camera video recording errors. + /// + /// This occurs when the video recording is not allowed or an unsupported + /// codec is used. + /// + /// MediaRecorder.error: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/error_event + Stream get onVideoRecordingError => + videoRecordingErrorController.stream; + + /// The stream controller for the [onVideoRecordingError] stream. + @visibleForTesting + final videoRecordingErrorController = + StreamController.broadcast(); + + StreamSubscription? _onVideoRecordingErrorSubscription; + /// The camera flash mode. @visibleForTesting FlashMode? flashMode; @@ -96,6 +114,41 @@ class Camera { @visibleForTesting html.Window? window = html.window; + /// The recorder used to record a video from the camera. + @visibleForTesting + html.MediaRecorder? mediaRecorder; + + /// Whether the video of the given type is supported. + @visibleForTesting + bool Function(String) isVideoTypeSupported = + html.MediaRecorder.isTypeSupported; + + /// The list of consecutive video data files recorded with [mediaRecorder]. + List _videoData = []; + + /// Completes when the video recording is stopped/finished. + Completer? _videoAvailableCompleter; + + /// A data listener fired when a new part of video data is available. + void Function(html.Event)? _videoDataAvailableListener; + + /// A listener fired when a video recording is stopped. + void Function(html.Event)? _videoRecordingStoppedListener; + + /// A builder to merge a list of blobs into a single blob. + @visibleForTesting + html.Blob Function(List blobs, String type) blobBuilder = + (blobs, type) => html.Blob(blobs, type); + + /// The stream that emits a [VideoRecordedEvent] when a video recording is created. + Stream get onVideoRecordedEvent => + videoRecorderController.stream; + + /// The stream controller for the [onVideoRecordedEvent] stream. + @visibleForTesting + final StreamController videoRecorderController = + StreamController.broadcast(); + /// Initializes the camera stream displayed in the [videoElement]. /// Registers the camera view with [textureId] under [_getViewType] type. /// Emits the camera default video track on the [onEnded] stream when it ends. @@ -130,7 +183,7 @@ class Camera { final defaultVideoTrack = videoTracks.first; _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { - onEndedStreamController.add(defaultVideoTrack); + onEndedController.add(defaultVideoTrack); }); } } @@ -158,7 +211,7 @@ class Camera { void stop() { final videoTracks = stream!.getVideoTracks(); if (videoTracks.isNotEmpty) { - onEndedStreamController.add(videoTracks.first); + onEndedController.add(videoTracks.first); } final tracks = stream?.getTracks(); @@ -365,23 +418,204 @@ class Camera { /// Returns the registered view type of the camera. String getViewType() => _getViewType(textureId); - /// Disposes the camera by stopping the camera stream - /// and reloading the camera source. + /// Starts a new video recording using [html.MediaRecorder]. + /// + /// Throws a [CameraWebException] if the provided maximum video duration is invalid + /// or the browser does not support any of the available video mime types + /// from [_videoMimeType]. + Future startVideoRecording({Duration? maxVideoDuration}) async { + if (maxVideoDuration != null && maxVideoDuration.inMilliseconds <= 0) { + throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The maximum video duration must be greater than 0 milliseconds.', + ); + } + + mediaRecorder ??= html.MediaRecorder(videoElement.srcObject!, { + 'mimeType': _videoMimeType, + }); + + _videoAvailableCompleter = Completer(); + + _videoDataAvailableListener = + (event) => _onVideoDataAvailable(event, maxVideoDuration); + + _videoRecordingStoppedListener = + (event) => _onVideoRecordingStopped(event, maxVideoDuration); + + mediaRecorder!.addEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.addEventListener( + 'stop', + _videoRecordingStoppedListener, + ); + + _onVideoRecordingErrorSubscription = + mediaRecorder!.onError.listen((html.Event event) { + final error = event as html.ErrorEvent; + if (error != null) { + videoRecordingErrorController.add(error); + } + }); + + if (maxVideoDuration != null) { + mediaRecorder!.start(maxVideoDuration.inMilliseconds); + } else { + // Don't pass the null duration as that will fire a `dataavailable` event directly. + mediaRecorder!.start(); + } + } + + void _onVideoDataAvailable( + html.Event event, [ + Duration? maxVideoDuration, + ]) { + final blob = (event as html.BlobEvent).data; + + // Append the recorded part of the video to the list of all video data files. + if (blob != null) { + _videoData.add(blob); + } + + // Stop the recorder if the video has a maxVideoDuration + // and the recording was not stopped manually. + if (maxVideoDuration != null && mediaRecorder!.state == 'recording') { + mediaRecorder!.stop(); + } + } + + Future _onVideoRecordingStopped( + html.Event event, [ + Duration? maxVideoDuration, + ]) async { + if (_videoData.isNotEmpty) { + // Concatenate all video data files into a single blob. + final videoType = _videoData.first.type; + final videoBlob = blobBuilder(_videoData, videoType); + + // Create a file containing the video blob. + final file = XFile( + html.Url.createObjectUrl(videoBlob), + mimeType: _videoMimeType, + name: videoBlob.hashCode.toString(), + ); + + // Emit an event containing the recorded video file. + videoRecorderController.add( + VideoRecordedEvent(this.textureId, file, maxVideoDuration), + ); + + _videoAvailableCompleter?.complete(file); + } + + // Clean up the media recorder with its event listeners and video data. + mediaRecorder!.removeEventListener( + 'dataavailable', + _videoDataAvailableListener, + ); + + mediaRecorder!.removeEventListener( + 'stop', + _videoDataAvailableListener, + ); + + await _onVideoRecordingErrorSubscription?.cancel(); + + mediaRecorder = null; + _videoDataAvailableListener = null; + _videoRecordingStoppedListener = null; + _videoData.clear(); + } + + /// Pauses the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future pauseVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.pause(); + } + + /// Resumes the current video recording. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future resumeVideoRecording() async { + if (mediaRecorder == null) { + throw _videoRecordingNotStartedException; + } + mediaRecorder!.resume(); + } + + /// Stops the video recording and returns the captured video file. + /// + /// Throws a [CameraWebException] if the video recorder is uninitialized. + Future stopVideoRecording() async { + if (mediaRecorder == null || _videoAvailableCompleter == null) { + throw _videoRecordingNotStartedException; + } + + mediaRecorder!.stop(); + + return _videoAvailableCompleter!.future; + } + + /// Disposes the camera by stopping the camera stream, + /// the video recording and reloading the camera source. Future dispose() async { - /// Stop the camera stream. + // Stop the camera stream. stop(); - /// Reset the [videoElement] to its initial state. + await videoRecorderController.close(); + mediaRecorder = null; + _videoDataAvailableListener = null; + + // Reset the [videoElement] to its initial state. videoElement ..srcObject = null ..load(); await _onEndedSubscription?.cancel(); _onEndedSubscription = null; + await onEndedController.close(); - await onEndedStreamController.close(); + await _onVideoRecordingErrorSubscription?.cancel(); + _onVideoRecordingErrorSubscription = null; + await videoRecordingErrorController.close(); + } + + /// Returns the first supported video mime type (amongst mp4 and webm) + /// to use when recording a video. + /// + /// Throws a [CameraWebException] if the browser does not support + /// any of the available video mime types. + String get _videoMimeType { + const types = [ + 'video/mp4', + 'video/webm', + ]; + + return types.firstWhere( + (type) => isVideoTypeSupported(type), + orElse: () => throw CameraWebException( + textureId, + CameraErrorCode.notSupported, + 'The browser does not support any of the following video types: ${types.join(',')}.', + ), + ); } + CameraWebException get _videoRecordingNotStartedException => + CameraWebException( + textureId, + CameraErrorCode.videoRecordingNotStarted, + 'The video recorder is uninitialized. The recording might not have been started. Make sure to call `startVideoRecording` first.', + ); + /// Applies default styles to the video [element]. void _applyDefaultVideoStyles(html.VideoElement element) { final isBackCamera = getLensDirection() == CameraLensDirection.back; diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 92c43c45b6b9..0021ee47cbde 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -64,6 +64,9 @@ class CameraPlugin extends CameraPlatform { final _cameraEndedSubscriptions = >{}; + final _cameraVideoRecordingErrorSubscriptions = + >{}; + /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream @@ -338,7 +341,7 @@ class CameraPlugin extends CameraPlatform { @override Stream onVideoRecordedEvent(int cameraId) { - throw UnimplementedError('onVideoRecordedEvent() is not implemented.'); + return getCamera(cameraId).onVideoRecordedEvent; } @override @@ -422,28 +425,73 @@ class CameraPlugin extends CameraPlatform { } @override - Future prepareForVideoRecording() { - throw UnimplementedError('prepareForVideoRecording() is not implemented.'); + Future prepareForVideoRecording() async { + // This is a no-op as it is not required for the web. } @override Future startVideoRecording(int cameraId, {Duration? maxVideoDuration}) { - throw UnimplementedError('startVideoRecording() is not implemented.'); + try { + final camera = getCamera(cameraId); + + // Add camera's video recording errors to the camera events stream. + // The error event fires when the video recording is not allowed or an unsupported + // codec is used. + _cameraVideoRecordingErrorSubscriptions[cameraId] = + camera.onVideoRecordingError.listen((html.ErrorEvent errorEvent) { + cameraEventStreamController.add( + CameraErrorEvent( + cameraId, + 'Error code: ${errorEvent.type}, error message: ${errorEvent.message}.', + ), + ); + }); + + return camera.startVideoRecording(maxVideoDuration: maxVideoDuration); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override - Future stopVideoRecording(int cameraId) { - throw UnimplementedError('stopVideoRecording() is not implemented.'); + Future stopVideoRecording(int cameraId) async { + try { + final videoRecording = await getCamera(cameraId).stopVideoRecording(); + await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); + return videoRecording; + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override Future pauseVideoRecording(int cameraId) { - throw UnimplementedError('pauseVideoRecording() is not implemented.'); + try { + return getCamera(cameraId).pauseVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override Future resumeVideoRecording(int cameraId) { - throw UnimplementedError('resumeVideoRecording() is not implemented.'); + try { + return getCamera(cameraId).resumeVideoRecording(); + } on html.DomException catch (e) { + throw PlatformException(code: e.name, message: e.message); + } on CameraWebException catch (e) { + _addCameraErrorEvent(e); + throw PlatformException(code: e.code.toString(), message: e.description); + } } @override @@ -571,6 +619,7 @@ class CameraPlugin extends CameraPlatform { await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); await _cameraEndedSubscriptions[cameraId]?.cancel(); + await _cameraVideoRecordingErrorSubscriptions[cameraId]?.cancel(); cameras.remove(cameraId); _cameraVideoErrorSubscriptions.remove(cameraId); diff --git a/packages/camera/camera_web/lib/src/types/camera_error_code.dart b/packages/camera/camera_web/lib/src/types/camera_error_code.dart index 210fa2baa9d2..f70925b4bede 100644 --- a/packages/camera/camera_web/lib/src/types/camera_error_code.dart +++ b/packages/camera/camera_web/lib/src/types/camera_error_code.dart @@ -68,6 +68,10 @@ class CameraErrorCode { static const CameraErrorCode notStarted = CameraErrorCode._('cameraNotStarted'); + /// The video recording was not started. + static const CameraErrorCode videoRecordingNotStarted = + CameraErrorCode._('videoRecordingNotStarted'); + /// An unknown camera error. static const CameraErrorCode unknown = CameraErrorCode._('cameraUnknown'); diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index fdfe3e38bb98..f001fe92365b 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/plugins/tree/master/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.2.0 +version: 0.2.1 environment: sdk: ">=2.12.0 <3.0.0"