Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0c6fac4
Add Support for Video Recording in Camera Web
ABausG Jul 31, 2021
65b2718
Add Documentation
ABausG Jul 31, 2021
4bd1173
Adding Tests
ABausG Jul 31, 2021
ce42759
Adding Error Handling
ABausG Jul 31, 2021
fdbe131
Formatting
ABausG Jul 31, 2021
4b1ae4c
Add missing call to get RecordedEvent Stream
ABausG Jul 31, 2021
2e6dfec
Rename onVideoRecordedEventStream
ABausG Aug 5, 2021
f252ffc
Unify logic from startVideoRecording and stopVideoRecording
ABausG Aug 5, 2021
d8114ab
throw PlatformExceptions
ABausG Aug 5, 2021
39adc96
Use srcObject
ABausG Aug 5, 2021
4db7748
Add Camera Tests
ABausG Aug 5, 2021
287bd4a
Fix Test and Stopping Video Recording
ABausG Aug 7, 2021
8f0f2fd
Clarify maxVideoDuration Test Issue
ABausG Aug 7, 2021
0e1d690
Merge branch 'upstream/master' into camera_web_recording
bselwe Aug 30, 2021
d38978f
feat: await closing videoRecorderController in Camera
bselwe Aug 30, 2021
ce5c1ca
docs: update video recording comments
bselwe Aug 30, 2021
a517757
feat: throw a CameraWebException if the video recording fails
bselwe Aug 30, 2021
2d1022f
test: add onEnded and onVideoRecordedEvent dispose tests
bselwe Aug 31, 2021
01a6999
test: update Camera video recording tests
bselwe Aug 31, 2021
4df6796
test: add missing CameraPlatform video recording tests
bselwe Aug 31, 2021
1d3906d
Merge branch 'upstream/master' into camera_web_recording
bselwe Aug 31, 2021
3c74e9e
feat: combine ondataavailable video blobs into a single video blob
bselwe Sep 3, 2021
f5863e9
test: update video recording tests for multiple video blobs
bselwe Sep 3, 2021
4552b01
docs: add video recording documentation
bselwe Sep 13, 2021
cd7f80d
feat: add Camera onVideoRecordingError stream
bselwe Sep 13, 2021
01b3210
test: add Camera onVideoRecordingError stream tests
bselwe Sep 13, 2021
c03fea6
feat: emit a CameraErrorEvent on video recording error
bselwe Sep 13, 2021
194db74
test: emit a CameraErrorEvent on video recording error tests
bselwe Sep 13, 2021
24ab126
Merge branch 'upstream/master' into camera_web_recording
bselwe Sep 14, 2021
787ee6b
feat: make prepareForVideoRecording a no-op
bselwe Sep 14, 2021
bc9c72a
Merge branch 'master' into camera_web_recording
ditman Sep 16, 2021
114cc46
Update CHANGELOG and pubspec
ditman Sep 16, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
256 changes: 230 additions & 26 deletions packages/camera/camera_web/example/integration_test/camera_web_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -720,35 +720,225 @@ void main() {
);
});

testWidgets('startVideoRecording throws UnimplementedError',
(tester) async {
expect(
() => CameraPlatform.instance.startVideoRecording(cameraId),
throwsUnimplementedError,
);
group('startVideoRecording', () {
testWidgets('starts Video Recording', (tester) async {
final camera = MockCamera();

when(camera.startVideoRecording).thenAnswer((_) => Future.value());

// Save the camera in the camera plugin.
(CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera;

await CameraPlatform.instance.startVideoRecording(cameraId);

verify(camera.startVideoRecording).called(1);
});

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 camera = MockCamera();
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('stopVideoRecording throws UnimplementedError', (tester) async {
expect(
() => CameraPlatform.instance.stopVideoRecording(cameraId),
throwsUnimplementedError,
);
group('stopVideoRecording', () {
testWidgets('stops 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
expect(video, capturedVideo);
expect(video, equals(capturedVideo));

});

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('pauseVideoRecording throws UnimplementedError',
(tester) async {
expect(
() => CameraPlatform.instance.pauseVideoRecording(cameraId),
throwsUnimplementedError,
);
group('pauseVideoRecording', () {
testWidgets('pauses Video Recording', (tester) async {
final camera = MockCamera();

when(camera.pauseVideoRecording).thenAnswer((_) => Future.value());

// 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('resumeVideoRecording throws UnimplementedError',
(tester) async {
expect(
() => CameraPlatform.instance.resumeVideoRecording(cameraId),
throwsUnimplementedError,
);
group('resumeVideoRecording', () {
testWidgets('resumes Video Recording', (tester) async {
final camera = MockCamera();

when(camera.resumeVideoRecording).thenAnswer((_) => Future.value());

// 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('setFlashMode throws UnimplementedError', (tester) async {
Expand Down Expand Up @@ -1205,11 +1395,25 @@ void main() {
});
});

testWidgets('onVideoRecordedEvent throws UnimplementedError',
testWidgets('onVideoRecordedEvent emits VideoRecordedEvent',
(tester) async {
final camera = MockCamera();
final capturedVideo = MockXFile();
final stream = Stream.value(
VideoRecordedEvent(cameraId, capturedVideo, Duration.zero));
when(() => camera.onVideoRecordedEventStream).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),
),
);
});

Expand Down
68 changes: 68 additions & 0 deletions packages/camera/camera_web/lib/src/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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' as html;
import 'dart:ui';

Expand Down Expand Up @@ -155,6 +156,8 @@ class Camera {
/// Stop the camera stream.
stop();

_videoRecorderController.close();

/// Reset the [videoElement] to its initial state.
videoElement
..srcObject = null
Expand All @@ -171,4 +174,69 @@ class Camera {
..objectFit = 'cover'
..transform = 'scaleX(-1)';
}

html.MediaRecorder? _mediaRecorder;
final StreamController<VideoRecordedEvent> _videoRecorderController =
StreamController();

/// Returns a Stream that emits when a video Recodring with a defined maxVideoDuration was created
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Returns a Stream that emits when a video Recodring with a defined maxVideoDuration was created
/// Returns a Stream that emits when a video recording with a defined maxVideoDuration was created.

Stream<VideoRecordedEvent> get onVideoRecordedEventStream =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about renaming it to onVideoRecorded for consistency with other stream names in the camera plugin (onCameraInitialized, onCameraResolutionChanged, etc.)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed it to onVideoRecordedEvent to match the naming of the getter in the camera_plugin_interface

_videoRecorderController.stream;

/// Starts a new Video Recording using [html.MediaRecorder]
/// /// Throws a [html.DomException.INVALID_STATE] if there already is an active Recording
Future<void> startVideoRecording({Duration? maxVideoDuration}) async {
if (_mediaRecorder != null && _mediaRecorder!.state != 'inactive') {
throw html.DomException.INVALID_STATE;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(See my general comment about the exception style within the plugin. @bselwe was overhauling this, and I don't think we'd want to throw a raw DomException, even from the low-level Camera object.

Copy link
Contributor

@bselwe bselwe Aug 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, AFAIK, it is not possible to create an instance of html.DomException (the class has only a private factory). It also seems that html.DomException.INVALID_STATE is a constant string rather than an exception so we would not be able to provide both code and description for the error.

I think the general rule would be to follow MethodChannelCamera (an implementation of the camera platform used for iOS and Android platforms) and CameraController:

  • Video recording methods in MethodChannelCamera invoke methods on the method channel - this may throw a PlatformException. For consistency, any error that is related to an invalid state/execution of the camera during video recording should throw a PlatformException as well.
  • We should rather avoid throwing a CameraException in internal classes (not exposed to the end user). If we throw platform exceptions, they are usually caught and mapped to camera exceptions in the CameraController.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing a PlatformException now and not checking fo the state of the mediaRecorder`

}
_mediaRecorder ??= html.MediaRecorder(
videoElement.captureStream(), {'mimeType': 'video/webm'});

if (maxVideoDuration != null) {
_mediaRecorder!.addEventListener('dataavailable', (event) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to remove the event listener (removeEventListener) when the video recording is stopped?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Removing it now

final blob = (event as html.BlobEvent).data;
final file = XFile(html.Url.createObjectUrl(blob));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file needs some extra data, like name and mimeType to be initialized at this point. This will help later when the user wants to save it to disk.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added mimeType and Filename. For now using the hashcode of the blob as filename

_videoRecorderController
.add(VideoRecordedEvent(this.textureId, file, maxVideoDuration));
_mediaRecorder!.stop();
});
_mediaRecorder!.start(maxVideoDuration.inMilliseconds);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if maxVideoDuration is 0 milliseconds? Should that be disallowed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could throw a DomException with NotSupportedError that would be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing a PlatformException

} else {
_mediaRecorder!.start();
}
}

/// Pauses the current video Recording
/// Throws a [html.DomException.INVALID_STATE] if there is no active Recording
Future<void> pauseVideoRecording() async {
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the docs, the _mediaRecorder state check is performed by the browser?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.pause would then be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above. Now throwing PlatformExceptions

throw html.DomException.INVALID_STATE;
}
_mediaRecorder?.pause();
}

/// Resumes a video Recording
/// Throws a [html.DomException.INVALID_STATE] if there is no active Recording
Future<void> resumeVideoRecording() async {
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
throw html.DomException.INVALID_STATE;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(These exceptions are already thrown by the browser, no need to assert for _mediaRecorder.state)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, same as above. It would be probably enough to throw a PlatformException with an appropriate code and description if _mediaRecorder is null. Any exception thrown by MediaRecorder.resume would then be caught in the camera platform here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

_mediaRecorder?.resume();
}

/// Stops the video Recording and will return the video as a webm video
/// Throws a [html.DomException.INVALID_STATE] if there is no active Recording
Future<XFile> stopVideoRecording() async {
if (_mediaRecorder == null || _mediaRecorder!.state == 'inactive') {
throw html.DomException.INVALID_STATE;
}
final availableData = Completer<XFile>();
_mediaRecorder!.addEventListener('dataavailable', (event) {
final blob = (event as html.BlobEvent).data;
availableData.complete(XFile(html.Url.createObjectUrl(blob)));
});
_mediaRecorder?.stop();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seems to be some code duplication here vs startVideoRecording (with a max length).

Could you unify these two codepaths so the implementation of thedataavailable event on startVideoRecording uses the same logic as "stop"?

I also wonder why the start with max length emits a VideoRecordedEvent, but stopVideoRecording doesn't?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unified it by registering the listener on dataavailable only in the startVideoRecording.

If the listener is triggered it will do the following things:

  • emit a VideoRecordedEvent
  • Complete the Completer used to obtain the XFile in stopVideoRecording
  • remove the listener
  • stop the MediaRecorder
    • This stop is necessary to only get data once if a maxVideoDuration is provided as MediaRecorder only takes in splices instead of a maxDuration


return availableData.future;
}
}
Loading