p-mazhnik

refactor: abstract web scanner

@@ -33,10 +33,6 @@ class MobileScannerWebPlugin { @@ -33,10 +33,6 @@ class MobileScannerWebPlugin {
33 // Controller to send events back to the framework 33 // Controller to send events back to the framework
34 StreamController controller = StreamController.broadcast(); 34 StreamController controller = StreamController.broadcast();
35 35
36 - // The video stream. Will be initialized later to see which camera needs to be used.  
37 - html.MediaStream? _localStream;  
38 - html.VideoElement video = html.VideoElement();  
39 -  
40 // ID of the video feed 36 // ID of the video feed
41 String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}'; 37 String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}';
42 38
@@ -46,8 +42,6 @@ class MobileScannerWebPlugin { @@ -46,8 +42,6 @@ class MobileScannerWebPlugin {
46 final WebBarcodeReaderBase _barCodeReader = JsQrCodeReader(); 42 final WebBarcodeReaderBase _barCodeReader = JsQrCodeReader();
47 StreamSubscription? _barCodeStreamSubscription; 43 StreamSubscription? _barCodeStreamSubscription;
48 44
49 - html.DivElement vidDiv = html.DivElement();  
50 -  
51 /// Handle incomming messages 45 /// Handle incomming messages
52 Future<dynamic> handleMethodCall(MethodCall call) async { 46 Future<dynamic> handleMethodCall(MethodCall call) async {
53 switch (call.method) { 47 switch (call.method) {
@@ -68,87 +62,48 @@ class MobileScannerWebPlugin { @@ -68,87 +62,48 @@ class MobileScannerWebPlugin {
68 62
69 /// Can enable or disable the flash if available 63 /// Can enable or disable the flash if available
70 Future<void> _torch(arguments) async { 64 Future<void> _torch(arguments) async {
71 - if (hasFlash) {  
72 - final track = _localStream?.getVideoTracks();  
73 - await track!.first.applyConstraints({  
74 - 'advanced': {'torch': arguments == 1}  
75 - });  
76 - } else { 65 + // if (hasFlash) {
  66 + // final track = _localStream?.getVideoTracks();
  67 + // await track!.first.applyConstraints({
  68 + // 'advanced': {'torch': arguments == 1}
  69 + // });
  70 + // } else {
  71 + // controller.addError('Device has no flash');
  72 + // }
77 controller.addError('Device has no flash'); 73 controller.addError('Device has no flash');
78 } 74 }
79 - }  
80 75
81 /// Starts the video stream and the scanner 76 /// Starts the video stream and the scanner
82 Future<Map> _start(Map arguments) async { 77 Future<Map> _start(Map arguments) async {
83 - vidDiv.children = [video];  
84 -  
85 var cameraFacing = CameraFacing.front; 78 var cameraFacing = CameraFacing.front;
86 if (arguments.containsKey('facing')) { 79 if (arguments.containsKey('facing')) {
87 cameraFacing = CameraFacing.values[arguments['facing'] as int]; 80 cameraFacing = CameraFacing.values[arguments['facing'] as int];
88 } 81 }
89 82
90 - // See https://github.com/flutter/flutter/issues/41563  
91 - // ignore: UNDEFINED_PREFIXED_NAME, avoid_dynamic_calls  
92 - ui.platformViewRegistry.registerViewFactory(  
93 - viewID,  
94 - (int id) => vidDiv  
95 - ..style.width = '100%'  
96 - ..style.height = '100%',  
97 - );  
98 -  
99 // Check if stream is running 83 // Check if stream is running
100 - if (_localStream != null) { 84 + if (_barCodeReader.isStarted) {
101 return { 85 return {
102 'ViewID': viewID, 86 'ViewID': viewID,
103 - 'videoWidth': video.videoWidth,  
104 - 'videoHeight': video.videoHeight 87 + 'videoWidth': _barCodeReader.videoWidth,
  88 + 'videoHeight': _barCodeReader.videoHeight
105 }; 89 };
106 } 90 }
107 -  
108 try { 91 try {
109 - // Check if browser supports multiple camera's and set if supported  
110 - final Map? capabilities =  
111 - html.window.navigator.mediaDevices?.getSupportedConstraints();  
112 - if (capabilities != null && capabilities['facingMode'] as bool) {  
113 - final constraints = {  
114 - 'video': VideoOptions(  
115 - facingMode:  
116 - cameraFacing == CameraFacing.front ? 'user' : 'environment',  
117 - )  
118 - };  
119 -  
120 - _localStream =  
121 - await html.window.navigator.mediaDevices?.getUserMedia(constraints);  
122 - } else {  
123 - _localStream = await html.window.navigator.mediaDevices  
124 - ?.getUserMedia({'video': true});  
125 - }  
126 -  
127 - video.srcObject = _localStream;  
128 -  
129 - // TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533  
130 - // final track = _localStream?.getVideoTracks();  
131 - // if (track != null) {  
132 - // final imageCapture = html.ImageCapture(track.first);  
133 - // final photoCapabilities = await imageCapture.getPhotoCapabilities();  
134 - // }  
135 -  
136 - // required to tell iOS safari we don't want fullscreen  
137 - video.setAttribute('playsinline', 'true'); 92 + await _barCodeReader.start(
  93 + viewID: viewID,
  94 + cameraFacing: cameraFacing,
  95 + );
138 96
139 - _barCodeStreamSubscription =  
140 - _barCodeReader.detectBarcodeContinuously(video).listen((code) {  
141 - if (_localStream == null) return; 97 + _barCodeStreamSubscription = _barCodeReader.detectBarcodeContinuously().listen((code) {
142 if (code != null) { 98 if (code != null) {
143 controller.add({'name': 'barcodeWeb', 'data': code}); 99 controller.add({'name': 'barcodeWeb', 'data': code});
144 } 100 }
145 }); 101 });
146 - await video.play();  
147 102
148 return { 103 return {
149 'ViewID': viewID, 104 'ViewID': viewID,
150 - 'videoWidth': video.videoWidth,  
151 - 'videoHeight': video.videoHeight, 105 + 'videoWidth': _barCodeReader.videoWidth,
  106 + 'videoHeight': _barCodeReader.videoHeight,
152 'torchable': hasFlash 107 'torchable': hasFlash
153 }; 108 };
154 } catch (e) { 109 } catch (e) {
@@ -172,19 +127,7 @@ class MobileScannerWebPlugin { @@ -172,19 +127,7 @@ class MobileScannerWebPlugin {
172 127
173 /// Stops the video feed and analyzer 128 /// Stops the video feed and analyzer
174 Future<void> cancel() async { 129 Future<void> cancel() async {
175 - try {  
176 - // Stop the camera stream  
177 - _localStream?.getTracks().forEach((track) {  
178 - if (track.readyState == 'live') {  
179 - track.stop();  
180 - }  
181 - });  
182 - } catch (e) {  
183 - debugPrint('Failed to stop stream: $e');  
184 - }  
185 -  
186 - video.srcObject = null;  
187 - _localStream = null; 130 + _barCodeReader.stop();
188 await _barCodeStreamSubscription?.cancel(); 131 await _barCodeStreamSubscription?.cancel();
189 _barCodeStreamSubscription = null; 132 _barCodeStreamSubscription = null;
190 } 133 }
1 -import 'dart:html'; 1 +import 'package:mobile_scanner/src/enums/camera_facing.dart';
2 2
3 abstract class WebBarcodeReaderBase { 3 abstract class WebBarcodeReaderBase {
4 - Stream<String?> detectBarcodeContinuously(VideoElement video); 4 + /// Timer used to capture frames to be analyzed
  5 + final frameInterval = const Duration(milliseconds: 200);
  6 +
  7 + bool get isStarted;
  8 +
  9 + int get videoWidth;
  10 + int get videoHeight;
  11 +
  12 + /// Starts streaming video
  13 + Future<void> start({
  14 + required String viewID,
  15 + required CameraFacing cameraFacing,
  16 + });
  17 +
  18 + /// Starts scanning QR codes or barcodes
  19 + Stream<String?> detectBarcodeContinuously();
  20 +
  21 + /// Stops streaming video
  22 + Future<void> stop();
5 } 23 }
@@ -4,10 +4,15 @@ library jsqr; @@ -4,10 +4,15 @@ library jsqr;
4 import 'dart:async'; 4 import 'dart:async';
5 import 'dart:html'; 5 import 'dart:html';
6 import 'dart:typed_data'; 6 import 'dart:typed_data';
  7 +import 'dart:ui' as ui;
7 8
  9 +import 'package:flutter/widgets.dart';
8 import 'package:js/js.dart'; 10 import 'package:js/js.dart';
  11 +import 'package:mobile_scanner/src/enums/camera_facing.dart';
9 import 'package:mobile_scanner/src/web/base.dart'; 12 import 'package:mobile_scanner/src/web/base.dart';
10 13
  14 +import 'media.dart';
  15 +
11 @JS('jsQR') 16 @JS('jsQR')
12 external Code? jsQR(dynamic data, int? width, int? height); 17 external Code? jsQR(dynamic data, int? width, int? height);
13 18
@@ -20,18 +25,77 @@ class Code { @@ -20,18 +25,77 @@ class Code {
20 25
21 26
22 class JsQrCodeReader extends WebBarcodeReaderBase { 27 class JsQrCodeReader extends WebBarcodeReaderBase {
23 - // Timer used to capture frames to be analyzed  
24 - final frameInterval = const Duration(milliseconds: 200); 28 + // The video stream. Will be initialized later to see which camera needs to be used.
  29 + MediaStream? _localStream;
  30 +
  31 + VideoElement video = VideoElement();
  32 +
  33 + DivElement vidDiv = DivElement();
  34 +
  35 + @override
  36 + bool get isStarted => _localStream != null;
  37 +
  38 + @override
  39 + int get videoWidth => video.width;
  40 + @override
  41 + int get videoHeight => video.height;
  42 +
  43 + @override
  44 + Future<void> start({
  45 + required String viewID,
  46 + required CameraFacing cameraFacing,
  47 + }) async {
  48 + vidDiv.children = [video];
  49 + // See https://github.com/flutter/flutter/issues/41563
  50 + // ignore: UNDEFINED_PREFIXED_NAME, avoid_dynamic_calls
  51 + ui.platformViewRegistry.registerViewFactory(
  52 + viewID,
  53 + (int id) => vidDiv
  54 + ..style.width = '100%'
  55 + ..style.height = '100%',
  56 + );
  57 + // Check if browser supports multiple camera's and set if supported
  58 + final Map? capabilities =
  59 + window.navigator.mediaDevices?.getSupportedConstraints();
  60 + if (capabilities != null && capabilities['facingMode'] as bool) {
  61 + final constraints = {
  62 + 'video': VideoOptions(
  63 + facingMode:
  64 + cameraFacing == CameraFacing.front ? 'user' : 'environment',
  65 + )
  66 + };
  67 +
  68 + _localStream =
  69 + await window.navigator.mediaDevices?.getUserMedia(constraints);
  70 + } else {
  71 + _localStream = await window.navigator.mediaDevices
  72 + ?.getUserMedia({'video': true});
  73 + }
  74 +
  75 + video.srcObject = _localStream;
  76 +
  77 + // TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533
  78 + // final track = _localStream?.getVideoTracks();
  79 + // if (track != null) {
  80 + // final imageCapture = html.ImageCapture(track.first);
  81 + // final photoCapabilities = await imageCapture.getPhotoCapabilities();
  82 + // }
  83 +
  84 + // required to tell iOS safari we don't want fullscreen
  85 + video.setAttribute('playsinline', 'true');
  86 + await video.play();
  87 + }
25 88
26 @override 89 @override
27 - Stream<String?> detectBarcodeContinuously(VideoElement video) async* { 90 + Stream<String?> detectBarcodeContinuously() async* {
28 yield* Stream.periodic(frameInterval, (_) { 91 yield* Stream.periodic(frameInterval, (_) {
29 return _captureFrame(video); 92 return _captureFrame(video);
30 - }).asyncMap((event) async => (await event)?.data); 93 + }).asyncMap((e) => e).map((event) => event?.data);
31 } 94 }
32 95
33 /// Captures a frame and analyzes it for QR codes 96 /// Captures a frame and analyzes it for QR codes
34 Future<Code?> _captureFrame(VideoElement video) async { 97 Future<Code?> _captureFrame(VideoElement video) async {
  98 + if (_localStream == null) return null;
35 final canvas = CanvasElement(width: video.videoWidth, height: video.videoHeight); 99 final canvas = CanvasElement(width: video.videoWidth, height: video.videoHeight);
36 final ctx = canvas.context2D; 100 final ctx = canvas.context2D;
37 101
@@ -41,4 +105,21 @@ class JsQrCodeReader extends WebBarcodeReaderBase { @@ -41,4 +105,21 @@ class JsQrCodeReader extends WebBarcodeReaderBase {
41 final code = jsQR(imgData.data, canvas.width, canvas.height); 105 final code = jsQR(imgData.data, canvas.width, canvas.height);
42 return code; 106 return code;
43 } 107 }
  108 +
  109 + @override
  110 + Future<void> stop() async {
  111 + try {
  112 + // Stop the camera stream
  113 + _localStream?.getTracks().forEach((track) {
  114 + if (track.readyState == 'live') {
  115 + track.stop();
  116 + }
  117 + });
  118 + } catch (e) {
  119 + debugPrint('Failed to stop stream: $e');
  120 + }
  121 +
  122 + video.srcObject = null;
  123 + _localStream = null;
  124 + }
44 } 125 }