[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"