From ee5561acfb1c5218ae0ef102f3ea56c96395c020 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:24:21 +0200 Subject: [PATCH 1/8] feat: move previous camera controller dispose to finally in case of an uncaught exception --- packages/camera/camera/example/lib/main.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 2314aecbece3..1648b64633ef 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -636,13 +636,13 @@ class _CameraExampleHomeState extends State ]); } on CameraException catch (e) { _showCameraException(e); - } + } finally { + if (mounted) { + setState(() {}); + } - if (mounted) { - setState(() {}); + await previousCameraController?.dispose(); } - - await previousCameraController?.dispose(); } void onTakePictureButtonPressed() { From b12cf93de635cc6f6650496090ce695d428d7055 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:25:08 +0200 Subject: [PATCH 2/8] feat: add camera onEnded events --- .../camera/camera_web/lib/src/camera.dart | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index c1343ceccf49..a98891e3993c 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.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' as html; import 'dart:ui'; @@ -67,6 +68,19 @@ class Camera { /// Initialized in [initialize] and [play], reset in [stop]. html.MediaStream? stream; + /// The stream of the camera stream tracks that have ended playing. + /// This occurs when there is no more camera stream data, e.g. + /// the user has stopped the stream by changing the camera device, + /// revoked the camera permissions or ejected the camera device. + Stream get onEnded => onEndedStreamController.stream; + + /// The stream controller for the [onEnded] stream. + @visibleForTesting + final onEndedStreamController = + StreamController.broadcast(); + + StreamSubscription? _onEndedSubscription; + /// The camera flash mode. @visibleForTesting FlashMode? flashMode; @@ -80,6 +94,7 @@ class Camera { /// 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. Future initialize() async { stream = await _cameraService.getMediaStreamForOptions( options, @@ -103,6 +118,16 @@ class Camera { ..muted = !options.audio.enabled ..srcObject = stream ..setAttribute('playsinline', ''); + + final videoTracks = stream!.getVideoTracks(); + + if (videoTracks.isNotEmpty) { + final defaultVideoTrack = videoTracks.first; + + _onEndedSubscription = defaultVideoTrack.onEnded.listen((html.Event _) { + onEndedStreamController.add(defaultVideoTrack); + }); + } } /// Starts the camera stream. @@ -126,7 +151,12 @@ class Camera { /// Stops the camera stream and resets the camera source. void stop() { - final tracks = videoElement.srcObject?.getTracks(); + final videoTracks = stream!.getVideoTracks(); + if (videoTracks.isNotEmpty) { + onEndedStreamController.add(videoTracks.first); + } + + final tracks = stream?.getTracks(); if (tracks != null) { for (final track in tracks) { track.stop(); @@ -303,7 +333,7 @@ class Camera { /// Disposes the camera by stopping the camera stream /// and reloading the camera source. - void dispose() { + Future dispose() async { /// Stop the camera stream. stop(); @@ -311,6 +341,11 @@ class Camera { videoElement ..srcObject = null ..load(); + + await _onEndedSubscription?.cancel(); + _onEndedSubscription = null; + + await onEndedStreamController.close(); } /// Applies default styles to the video [element]. From 7c762bfe9d701cf9127ef273da3c295fec9e6d3a Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:25:19 +0200 Subject: [PATCH 3/8] test: add camera onEnded tests --- .../example/integration_test/camera_test.dart | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) 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 1d1659352f26..f331cc1485ab 100644 --- a/packages/camera/camera_web/example/integration_test/camera_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_test.dart @@ -5,6 +5,7 @@ import 'dart:html'; import 'dart:ui'; +import 'package:async/async.dart'; import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:camera_web/src/camera.dart'; import 'package:camera_web/src/camera_service.dart'; @@ -843,10 +844,81 @@ void main() { await camera.initialize(); - camera.dispose(); + await camera.dispose(); expect(camera.videoElement.srcObject, isNull); }); }); + + group('events', () { + group('onEnded', () { + testWidgets( + 'emits the default video track ' + 'when it emits an ended event', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final streamQueue = StreamQueue(camera.onEnded); + + await camera.initialize(); + + final videoTracks = camera.stream!.getVideoTracks(); + final defaultVideoTrack = videoTracks.first; + + defaultVideoTrack.dispatchEvent(Event('ended')); + + expect( + await streamQueue.next, + equals(defaultVideoTrack), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'emits the default video track ' + 'when the camera is stopped', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + final streamQueue = StreamQueue(camera.onEnded); + + await camera.initialize(); + + final videoTracks = camera.stream!.getVideoTracks(); + final defaultVideoTrack = videoTracks.first; + + camera.stop(); + + expect( + await streamQueue.next, + equals(defaultVideoTrack), + ); + + await streamQueue.cancel(); + }); + + testWidgets( + 'no longer emits the default video track ' + 'when the camera is disposed', (tester) async { + final camera = Camera( + textureId: textureId, + cameraService: cameraService, + ); + + await camera.initialize(); + await camera.dispose(); + + expect( + camera.onEndedStreamController.isClosed, + isTrue, + ); + }); + }); + }); }); } From 3f19f093c9ffdde21df7e0fd62eea2331a15dfe0 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:25:35 +0200 Subject: [PATCH 4/8] feat: add onCameraClosing implementation --- .../camera/camera_web/lib/src/camera_web.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index 8b131f5d4f6e..19ee43f36660 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -61,6 +61,9 @@ class CameraPlugin extends CameraPlatform { final _cameraVideoAbortSubscriptions = >{}; + final _cameraEndedSubscriptions = + >{}; + /// Returns a stream of camera events for the given [cameraId]. Stream _cameraEvents(int cameraId) => cameraEventStreamController.stream @@ -273,6 +276,15 @@ class CameraPlugin extends CameraPlatform { await camera.play(); + // Add camera's closing events to the camera events stream. + // The onEnded stream fires when there is no more camera stream data. + _cameraEndedSubscriptions[cameraId] = + camera.onEnded.listen((html.MediaStreamTrack _) { + cameraEventStreamController.add( + CameraClosingEvent(cameraId), + ); + }); + final cameraSize = await camera.getVideoSize(); cameraEventStreamController.add( @@ -313,7 +325,7 @@ class CameraPlugin extends CameraPlatform { @override Stream onCameraClosing(int cameraId) { - throw UnimplementedError('onCameraClosing() is not implemented.'); + return _cameraEvents(cameraId).whereType(); } @override @@ -548,13 +560,15 @@ class CameraPlugin extends CameraPlatform { @override Future dispose(int cameraId) async { try { - getCamera(cameraId).dispose(); + await getCamera(cameraId).dispose(); await _cameraVideoErrorSubscriptions[cameraId]?.cancel(); await _cameraVideoAbortSubscriptions[cameraId]?.cancel(); + await _cameraEndedSubscriptions[cameraId]?.cancel(); cameras.remove(cameraId); _cameraVideoErrorSubscriptions.remove(cameraId); _cameraVideoAbortSubscriptions.remove(cameraId); + _cameraEndedSubscriptions.remove(cameraId); } on html.DomException catch (e) { throw PlatformException(code: e.name, message: e.message); } From c063d473ccd84249dffa0eac099439c2eab35929 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:25:46 +0200 Subject: [PATCH 5/8] test: add onCameraClosing tests --- .../integration_test/camera_web_test.dart | 195 ++++++++++++------ 1 file changed, 137 insertions(+), 58 deletions(-) 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 d48df122277f..9ab8c511f753 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 @@ -563,24 +563,34 @@ void main() { late Camera camera; late VideoElement videoElement; + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + setUp(() { camera = MockCamera(); videoElement = MockVideoElement(); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError) - .thenAnswer((_) => FakeElementStream(Stream.empty())); - when(() => videoElement.onAbort) - .thenAnswer((_) => FakeElementStream(Stream.empty())); - }); + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); - testWidgets('initializes and plays the camera', (tester) async { when(camera.getVideoSize).thenAnswer( (_) => Future.value(Size(10, 10)), ); when(camera.initialize).thenAnswer((_) => Future.value()); when(camera.play).thenAnswer((_) => Future.value()); + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + + testWidgets('initializes and plays the camera', (tester) async { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; @@ -590,6 +600,32 @@ void main() { verify(camera.play).called(1); }); + testWidgets('starts listening to the camera video error and abort events', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect(errorStreamController.hasListener, isFalse); + expect(abortStreamController.hasListener, isFalse); + + await CameraPlatform.instance.initializeCamera(cameraId); + + expect(errorStreamController.hasListener, isTrue); + expect(abortStreamController.hasListener, isTrue); + }); + + testWidgets('starts listening to the camera ended events', + (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; + + expect(endedStreamController.hasListener, isFalse); + + await CameraPlatform.instance.initializeCamera(cameraId); + + expect(endedStreamController.hasListener, isTrue); + }); + group('throws PlatformException', () { testWidgets( 'with notFound error ' @@ -1610,6 +1646,37 @@ void main() { }); group('dispose', () { + late Camera camera; + late VideoElement videoElement; + + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + when(camera.dispose).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + testWidgets('disposes the correct camera', (tester) async { const firstCameraId = 0; const secondCameraId = 1; @@ -1642,38 +1709,26 @@ void main() { ); }); - testWidgets('cancels camera video and abort error subscriptions', + testWidgets('cancels the camera video error and abort subscriptions', (tester) async { - final camera = MockCamera(); - final videoElement = MockVideoElement(); - - final errorStreamController = StreamController(); - final abortStreamController = StreamController(); + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); - when(camera.initialize).thenAnswer((_) => Future.value()); - when(camera.play).thenAnswer((_) => Future.value()); + await CameraPlatform.instance.initializeCamera(cameraId); + await CameraPlatform.instance.dispose(cameraId); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError) - .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort) - .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + expect(errorStreamController.hasListener, isFalse); + expect(abortStreamController.hasListener, isFalse); + }); + testWidgets('cancels the camera ended subscriptions', (tester) async { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; await CameraPlatform.instance.initializeCamera(cameraId); - - expect(errorStreamController.hasListener, isTrue); - expect(abortStreamController.hasListener, isTrue); - await CameraPlatform.instance.dispose(cameraId); - expect(errorStreamController.hasListener, isFalse); - expect(abortStreamController.hasListener, isFalse); + expect(endedStreamController.hasListener, isFalse); }); group('throws PlatformException', () { @@ -1749,6 +1804,36 @@ void main() { }); group('events', () { + late Camera camera; + late VideoElement videoElement; + + late StreamController errorStreamController, abortStreamController; + late StreamController endedStreamController; + + setUp(() { + camera = MockCamera(); + videoElement = MockVideoElement(); + + errorStreamController = StreamController(); + abortStreamController = StreamController(); + endedStreamController = StreamController(); + + when(camera.getVideoSize).thenAnswer( + (_) => Future.value(Size(10, 10)), + ); + when(camera.initialize).thenAnswer((_) => Future.value()); + when(camera.play).thenAnswer((_) => Future.value()); + + when(() => camera.videoElement).thenReturn(videoElement); + when(() => videoElement.onError) + .thenAnswer((_) => FakeElementStream(errorStreamController.stream)); + when(() => videoElement.onAbort) + .thenAnswer((_) => FakeElementStream(abortStreamController.stream)); + + when(() => camera.onEnded) + .thenAnswer((_) => endedStreamController.stream); + }); + testWidgets( 'onCameraInitialized emits a CameraInitializedEvent ' 'on initializeCamera', (tester) async { @@ -1805,46 +1890,40 @@ void main() { ); }); - testWidgets('onCameraClosing throws UnimplementedError', (tester) async { - expect( - () => CameraPlatform.instance.onCameraClosing(cameraId), - throwsUnimplementedError, - ); - }); + testWidgets( + 'onCameraClosing emits a CameraClosingEvent ' + 'on the camera ended event', (tester) async { + // Save the camera in the camera plugin. + (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; - group('onCameraError', () { - late Camera camera; - late VideoElement videoElement; + final Stream eventStream = + CameraPlatform.instance.onCameraClosing(cameraId); - late StreamController errorStreamController, - abortStreamController; + final streamQueue = StreamQueue(eventStream); - setUp(() { - camera = MockCamera(); - videoElement = MockVideoElement(); + await CameraPlatform.instance.initializeCamera(cameraId); - errorStreamController = StreamController(); - abortStreamController = StreamController(); + endedStreamController.add(MockMediaStreamTrack()); - when(camera.getVideoSize).thenAnswer( - (_) => Future.value(Size(10, 10)), - ); - when(camera.initialize).thenAnswer((_) => Future.value()); - when(camera.play).thenAnswer((_) => Future.value()); + expect( + await streamQueue.next, + equals( + CameraClosingEvent(cameraId), + ), + ); - when(() => camera.videoElement).thenReturn(videoElement); - when(() => videoElement.onError).thenAnswer( - (_) => FakeElementStream(errorStreamController.stream)); - when(() => videoElement.onAbort).thenAnswer( - (_) => FakeElementStream(abortStreamController.stream)); + await streamQueue.cancel(); + }); + group('onCameraError', () { + setUp(() { // Save the camera in the camera plugin. (CameraPlatform.instance as CameraPlugin).cameras[cameraId] = camera; }); testWidgets( 'emits a CameraErrorEvent ' - 'on initialize video error ' + 'on the camera video error event ' 'with a message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1879,7 +1958,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on initialize video error ' + 'on the camera video error event ' 'with no message', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); @@ -1910,7 +1989,7 @@ void main() { testWidgets( 'emits a CameraErrorEvent ' - 'on initialize abort error', (tester) async { + 'on the camera video abort event', (tester) async { final Stream eventStream = CameraPlatform.instance.onCameraError(cameraId); From 1e34e63e98165b90724d86aad1b1a8ac16bf3792 Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:33:42 +0200 Subject: [PATCH 6/8] docs: update comments --- packages/camera/camera_web/lib/src/camera.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index a98891e3993c..f936c7a45c5f 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -68,7 +68,7 @@ class Camera { /// Initialized in [initialize] and [play], reset in [stop]. html.MediaStream? stream; - /// The stream of the camera stream tracks that have ended playing. + /// The stream of the camera video tracks that have ended playing. /// This occurs when there is no more camera stream data, e.g. /// the user has stopped the stream by changing the camera device, /// revoked the camera permissions or ejected the camera device. From 821d97b280ecefa601681207718f024ad999693f Mon Sep 17 00:00:00 2001 From: Bartosz Selwesiuk Date: Tue, 24 Aug 2021 18:39:30 +0200 Subject: [PATCH 7/8] docs: update comments --- packages/camera/camera_web/lib/src/camera.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index f936c7a45c5f..74d8546fbb12 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -69,9 +69,13 @@ class Camera { html.MediaStream? stream; /// The stream of the camera video tracks that have ended playing. + /// /// This occurs when there is no more camera stream data, e.g. /// the user has stopped the stream by changing the camera device, /// revoked the camera permissions or ejected the camera device. + /// + /// MediaStreamTrack.onended: + /// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/onended Stream get onEnded => onEndedStreamController.stream; /// The stream controller for the [onEnded] stream. From 61253cb701e62fa55485bb4236e15d7b7e277d38 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Wed, 25 Aug 2021 16:33:03 -0700 Subject: [PATCH 8/8] Revert "feat: move previous camera controller dispose to finally in case of an uncaught exception" This reverts commit ee5561acfb1c5218ae0ef102f3ea56c96395c020. --- packages/camera/camera/example/lib/main.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 1648b64633ef..2314aecbece3 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -636,13 +636,13 @@ class _CameraExampleHomeState extends State ]); } on CameraException catch (e) { _showCameraException(e); - } finally { - if (mounted) { - setState(() {}); - } + } - await previousCameraController?.dispose(); + if (mounted) { + setState(() {}); } + + await previousCameraController?.dispose(); } void onTakePictureButtonPressed() {