[camera_web] Add video recording functionality (#4210)
* feat: Add Support for Video Recording in Camera Web
* docs: add video recording documentation
Co-authored-by: Bartosz Selwesiuk <bselwesiuk@gmail.com>
diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart
index f21a3b1..8cf1e90 100644
--- a/packages/camera/camera/lib/src/camera_controller.dart
+++ b/packages/camera/camera/lib/src/camera_controller.dart
@@ -335,7 +335,7 @@
/// 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<void> prepareForVideoRecording() async {
diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md
index 098fe62..8596b35 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 032a345..918e695 100644
--- a/packages/camera/camera_web/README.md
+++ b/packages/camera/camera_web/README.md
@@ -83,11 +83,24 @@
}
```
+### 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 d0250c6..a298b57 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 @@
);
});
+ 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 712d8c7..3a25e33 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 @@
).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 @@
).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 @@
.thenReturn(zoomLevelCapability);
expect(
- () => camera.setZoomLevel(105.0),
- throwsA(
- isA<CameraWebException>()
- .having(
- (e) => e.cameraId,
- 'cameraId',
- textureId,
- )
- .having(
- (e) => e.code,
- 'code',
- CameraErrorCode.zoomLevelInvalid,
- ),
- ));
+ () => camera.setZoomLevel(105.0),
+ throwsA(
+ isA<CameraWebException>()
+ .having(
+ (e) => e.cameraId,
+ 'cameraId',
+ textureId,
+ )
+ .having(
+ (e) => e.code,
+ 'code',
+ CameraErrorCode.zoomLevelInvalid,
+ ),
+ ),
+ );
});
});
});
@@ -943,6 +945,503 @@
});
});
+ 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<CameraWebException>()
+ .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<CameraWebException>()
+ .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<CameraWebException>()
+ .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<CameraWebException>()
+ .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<Blob>? 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<CameraWebException>()
+ .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<ErrorEvent>();
+
+ 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 @@
);
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<VideoRecordedEvent>()
+ .having(
+ (e) => e.cameraId,
+ 'cameraId',
+ textureId,
+ )
+ .having(
+ (e) => e.file,
+ 'file',
+ isA<XFile>()
+ .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 @@
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<ErrorEvent>();
+
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 f469f3c..9749559 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 @@
});
});
- testWidgets('prepareForVideoRecording throws UnimplementedError',
- (tester) async {
- expect(
- () => CameraPlatform.instance.prepareForVideoRecording(),
- throwsUnimplementedError,
- );
+ group('startVideoRecording', () {
+ late Camera camera;
+
+ 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<ErrorEvent>();
+
+ 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<PlatformException>().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<PlatformException>().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<PlatformException>().having(
+ (e) => e.code,
+ 'code',
+ exception.code.toString(),
+ ),
+ ),
+ );
+ });
+ });
});
- testWidgets('startVideoRecording throws UnimplementedError',
- (tester) async {
- expect(
- () => CameraPlatform.instance.startVideoRecording(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<ErrorEvent>();
+
+ 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<PlatformException>().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<PlatformException>().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<PlatformException>().having(
+ (e) => e.code,
+ 'code',
+ exception.code.toString(),
+ ),
+ ),
+ );
+ });
+ });
});
- testWidgets('stopVideoRecording throws UnimplementedError', (tester) async {
- expect(
- () => CameraPlatform.instance.stopVideoRecording(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<PlatformException>().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<PlatformException>().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<PlatformException>().having(
+ (e) => e.code,
+ 'code',
+ exception.code.toString(),
+ ),
+ ),
+ );
+ });
+ });
});
- testWidgets('pauseVideoRecording throws UnimplementedError',
- (tester) async {
- expect(
- () => CameraPlatform.instance.pauseVideoRecording(cameraId),
- throwsUnimplementedError,
- );
- });
+ group('resumeVideoRecording', () {
+ testWidgets('resumes a video recording', (tester) async {
+ final camera = MockCamera();
- testWidgets('resumeVideoRecording throws UnimplementedError',
- (tester) async {
- expect(
- () => CameraPlatform.instance.resumeVideoRecording(cameraId),
- throwsUnimplementedError,
- );
+ 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<PlatformException>().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<PlatformException>().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<PlatformException>().having(
+ (e) => e.code,
+ 'code',
+ exception.code.toString(),
+ ),
+ ),
+ );
+ });
+ });
});
group('setFlashMode', () {
@@ -1676,6 +2010,7 @@
late StreamController<Event> errorStreamController, abortStreamController;
late StreamController<MediaStreamTrack> endedStreamController;
+ late StreamController<ErrorEvent> videoRecordingErrorController;
setUp(() {
camera = MockCamera();
@@ -1684,6 +2019,7 @@
errorStreamController = StreamController<Event>();
abortStreamController = StreamController<Event>();
endedStreamController = StreamController<MediaStreamTrack>();
+ videoRecordingErrorController = StreamController<ErrorEvent>();
when(camera.getVideoSize).thenReturn(Size(10, 10));
when(camera.initialize).thenAnswer((_) => Future.value());
@@ -1698,6 +2034,11 @@
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 @@
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 @@
late StreamController<Event> errorStreamController, abortStreamController;
late StreamController<MediaStreamTrack> endedStreamController;
+ late StreamController<ErrorEvent> videoRecordingErrorController;
setUp(() {
camera = MockCamera();
@@ -1840,6 +2194,7 @@
errorStreamController = StreamController<Event>();
abortStreamController = StreamController<Event>();
endedStreamController = StreamController<MediaStreamTrack>();
+ videoRecordingErrorController = StreamController<ErrorEvent>();
when(camera.getVideoSize).thenReturn(Size(10, 10));
when(camera.initialize).thenAnswer((_) => Future.value());
@@ -1853,6 +2208,11 @@
when(() => camera.onEnded)
.thenAnswer((_) => endedStreamController.stream);
+
+ when(() => camera.onVideoRecordingError)
+ .thenAnswer((_) => videoRecordingErrorController.stream);
+
+ when(() => camera.startVideoRecording()).thenAnswer((_) async => {});
});
testWidgets(
@@ -2258,13 +2618,210 @@
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<CameraErrorEvent> eventStream =
+ CameraPlatform.instance.onCameraError(cameraId);
+
+ final streamQueue = StreamQueue(eventStream);
+
+ expect(
+ () async =>
+ await CameraPlatform.instance.startVideoRecording(cameraId),
+ throwsA(
+ isA<PlatformException>(),
+ ),
+ );
+
+ 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<CameraErrorEvent> 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<CameraErrorEvent> eventStream =
+ CameraPlatform.instance.onCameraError(cameraId);
+
+ final streamQueue = StreamQueue(eventStream);
+
+ expect(
+ () async =>
+ await CameraPlatform.instance.stopVideoRecording(cameraId),
+ throwsA(
+ isA<PlatformException>(),
+ ),
+ );
+
+ 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<CameraErrorEvent> eventStream =
+ CameraPlatform.instance.onCameraError(cameraId);
+
+ final streamQueue = StreamQueue(eventStream);
+
+ expect(
+ () async =>
+ await CameraPlatform.instance.pauseVideoRecording(cameraId),
+ throwsA(
+ isA<PlatformException>(),
+ ),
+ );
+
+ 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<CameraErrorEvent> eventStream =
+ CameraPlatform.instance.onCameraError(cameraId);
+
+ final streamQueue = StreamQueue(eventStream);
+
+ expect(
+ () async =>
+ await CameraPlatform.instance.resumeVideoRecording(cameraId),
+ throwsA(
+ isA<PlatformException>(),
+ ),
+ );
+
+ 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 e6a11cc..77e9077 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 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 @@
}
}
+/// 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 4b7a185..cf01870 100644
--- a/packages/camera/camera_web/lib/src/camera.dart
+++ b/packages/camera/camera_web/lib/src/camera.dart
@@ -26,8 +26,10 @@
/// 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 @@
///
/// MediaStreamTrack.onended:
/// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended
- Stream<html.MediaStreamTrack> get onEnded => onEndedStreamController.stream;
+ Stream<html.MediaStreamTrack> get onEnded => onEndedController.stream;
/// The stream controller for the [onEnded] stream.
@visibleForTesting
- final onEndedStreamController =
- StreamController<html.MediaStreamTrack>.broadcast();
+ final onEndedController = StreamController<html.MediaStreamTrack>.broadcast();
StreamSubscription<html.Event>? _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<html.ErrorEvent> get onVideoRecordingError =>
+ videoRecordingErrorController.stream;
+
+ /// The stream controller for the [onVideoRecordingError] stream.
+ @visibleForTesting
+ final videoRecordingErrorController =
+ StreamController<html.ErrorEvent>.broadcast();
+
+ StreamSubscription<html.Event>? _onVideoRecordingErrorSubscription;
+
/// The camera flash mode.
@visibleForTesting
FlashMode? flashMode;
@@ -96,6 +114,41 @@
@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<html.Blob> _videoData = [];
+
+ /// Completes when the video recording is stopped/finished.
+ Completer<XFile>? _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<html.Blob> blobs, String type) blobBuilder =
+ (blobs, type) => html.Blob(blobs, type);
+
+ /// The stream that emits a [VideoRecordedEvent] when a video recording is created.
+ Stream<VideoRecordedEvent> get onVideoRecordedEvent =>
+ videoRecorderController.stream;
+
+ /// The stream controller for the [onVideoRecordedEvent] stream.
+ @visibleForTesting
+ final StreamController<VideoRecordedEvent> videoRecorderController =
+ StreamController<VideoRecordedEvent>.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 @@
final defaultVideoTrack = videoTracks.first;
_onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) {
- onEndedStreamController.add(defaultVideoTrack);
+ onEndedController.add(defaultVideoTrack);
});
}
}
@@ -158,7 +211,7 @@
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 @@
/// 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<void> 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<XFile>();
+
+ _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<void> _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<void> pauseVideoRecording() async {
+ if (mediaRecorder == null) {
+ throw _videoRecordingNotStartedException;
+ }
+ mediaRecorder!.pause();
+ }
+
+ /// Resumes the current video recording.
+ ///
+ /// Throws a [CameraWebException] if the video recorder is uninitialized.
+ Future<void> 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<XFile> 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<void> 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 92c43c4..0021ee4 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 @@
final _cameraEndedSubscriptions =
<int, StreamSubscription<html.MediaStreamTrack>>{};
+ final _cameraVideoRecordingErrorSubscriptions =
+ <int, StreamSubscription<html.ErrorEvent>>{};
+
/// Returns a stream of camera events for the given [cameraId].
Stream<CameraEvent> _cameraEvents(int cameraId) =>
cameraEventStreamController.stream
@@ -338,7 +341,7 @@
@override
Stream<VideoRecordedEvent> onVideoRecordedEvent(int cameraId) {
- throw UnimplementedError('onVideoRecordedEvent() is not implemented.');
+ return getCamera(cameraId).onVideoRecordedEvent;
}
@override
@@ -422,28 +425,73 @@
}
@override
- Future<void> prepareForVideoRecording() {
- throw UnimplementedError('prepareForVideoRecording() is not implemented.');
+ Future<void> prepareForVideoRecording() async {
+ // This is a no-op as it is not required for the web.
}
@override
Future<void> 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<XFile> stopVideoRecording(int cameraId) {
- throw UnimplementedError('stopVideoRecording() is not implemented.');
+ Future<XFile> 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<void> 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<void> 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 @@
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 210fa2b..f70925b 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 @@
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 fdfe3e3..f001fe9 100644
--- a/packages/camera/camera_web/pubspec.yaml
+++ b/packages/camera/camera_web/pubspec.yaml
@@ -2,7 +2,7 @@
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"