Julian Steenbakker

feat: update web integration

  1 +import 'dart:async';
  2 +
  3 +import 'package:flutter/material.dart';
  4 +import 'package:flutter/services.dart';
  5 +import 'package:flutter_web_plugins/flutter_web_plugins.dart';
  6 +import 'package:mobile_scanner/mobile_scanner.dart';
  7 +import 'package:mobile_scanner/src/web/jsqr.dart';
  8 +import 'dart:html' as html;
  9 +import 'dart:ui' as ui;
  10 +
  11 +import 'package:mobile_scanner/src/web/media.dart';
  12 +
  13 +/// This plugin is the web implementation of mobile_scanner.
  14 +/// It only supports QR codes.
  15 +class MobileScannerWebPlugin {
  16 + static void registerWith(Registrar registrar) {
  17 + PluginEventChannel event = PluginEventChannel(
  18 + 'dev.steenbakker.mobile_scanner/scanner/event',
  19 + const StandardMethodCodec(),
  20 + registrar);
  21 + MethodChannel channel = MethodChannel(
  22 + 'dev.steenbakker.mobile_scanner/scanner/method',
  23 + const StandardMethodCodec(),
  24 + registrar);
  25 + final MobileScannerWebPlugin instance = MobileScannerWebPlugin();
  26 + WidgetsFlutterBinding.ensureInitialized();
  27 +
  28 + channel.setMethodCallHandler(instance.handleMethodCall);
  29 + event.setController(instance.controller);
  30 + }
  31 +
  32 + // Controller to send events back to the framework
  33 + StreamController controller = StreamController();
  34 +
  35 + // The video stream. Will be initialized later to see which camera needs to be used.
  36 + html.MediaStream? _localStream;
  37 + html.VideoElement video = html.VideoElement();
  38 +
  39 + // ID of the video feed
  40 + String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}';
  41 +
  42 + // Determine wether device has flas
  43 + bool hasFlash = false;
  44 +
  45 + // Timer used to capture frames to be analyzed
  46 + Timer? _frameInterval;
  47 +
  48 + html.DivElement vidDiv = html.DivElement();
  49 +
  50 + /// Handle incomming messages
  51 + Future<dynamic> handleMethodCall(MethodCall call) async {
  52 + switch (call.method) {
  53 + case 'start':
  54 + return await _start(call.arguments);
  55 + case 'torch':
  56 + return await _torch(call.arguments);
  57 + case 'stop':
  58 + return await cancel();
  59 + default:
  60 + throw PlatformException(
  61 + code: 'Unimplemented',
  62 + details: "The mobile_scanner plugin for web doesn't implement "
  63 + "the method '${call.method}'");
  64 + }
  65 + }
  66 +
  67 + /// Can enable or disable the flash if available
  68 + Future<void> _torch(arguments) async {
  69 + if (hasFlash) {
  70 + final track = _localStream?.getVideoTracks();
  71 + await track!.first.applyConstraints({
  72 + 'advanced': {'torch': arguments == 1}
  73 + });
  74 + } else {
  75 + controller.addError('Device has no flash');
  76 + }
  77 + }
  78 +
  79 + /// Starts the video stream and the scanner
  80 + Future<Map> _start(arguments) async {
  81 + vidDiv.children = [video];
  82 +
  83 + final CameraFacing cameraFacing =
  84 + arguments['cameraFacing'] ?? CameraFacing.front;
  85 +
  86 + // See https://github.com/flutter/flutter/issues/41563
  87 + // ignore: UNDEFINED_PREFIXED_NAME
  88 + ui.platformViewRegistry.registerViewFactory(
  89 + viewID,
  90 + (int id) => vidDiv
  91 + ..style.width = '100%'
  92 + ..style.height = '100%');
  93 +
  94 + // Check if stream is running
  95 + if (_localStream != null) {
  96 + return {
  97 + 'ViewID': viewID,
  98 + 'videoWidth': video.videoWidth,
  99 + 'videoHeight': video.videoHeight
  100 + };
  101 + }
  102 +
  103 + try {
  104 + // Check if browser supports multiple camera's and set if supported
  105 + Map? capabilities =
  106 + html.window.navigator.mediaDevices?.getSupportedConstraints();
  107 + if (capabilities != null && capabilities['facingMode']) {
  108 + UserMediaOptions constraints = UserMediaOptions(
  109 + video: VideoOptions(
  110 + facingMode:
  111 + (cameraFacing == CameraFacing.front ? 'user' : 'environment'),
  112 + width: {'ideal': 4096},
  113 + height: {'ideal': 2160},
  114 + ));
  115 +
  116 + _localStream =
  117 + await html.window.navigator.getUserMedia(video: constraints);
  118 + } else {
  119 + _localStream = await html.window.navigator.getUserMedia(video: true);
  120 + }
  121 +
  122 + video.srcObject = _localStream;
  123 +
  124 + // TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533
  125 + // final track = _localStream?.getVideoTracks();
  126 + // if (track != null) {
  127 + // final imageCapture = html.ImageCapture(track.first);
  128 + // final photoCapabilities = await imageCapture.getPhotoCapabilities();
  129 + // }
  130 +
  131 + // required to tell iOS safari we don't want fullscreen
  132 + video.setAttribute('playsinline', 'true');
  133 +
  134 + await video.play();
  135 +
  136 + // Then capture a frame to be analyzed every 200 miliseconds
  137 + _frameInterval =
  138 + Timer.periodic(const Duration(milliseconds: 200), (timer) {
  139 + _captureFrame();
  140 + });
  141 +
  142 + return {
  143 + 'ViewID': viewID,
  144 + 'videoWidth': video.videoWidth,
  145 + 'videoHeight': video.videoHeight,
  146 + 'torchable': hasFlash
  147 + };
  148 + } catch (e) {
  149 + throw PlatformException(code: 'MobileScannerWeb', message: e.toString());
  150 + }
  151 + }
  152 +
  153 + /// Check if any camera's are available
  154 + static Future<bool> cameraAvailable() async {
  155 + final sources =
  156 + await html.window.navigator.mediaDevices!.enumerateDevices();
  157 + for (final e in sources) {
  158 + if (e.kind == 'videoinput') {
  159 + return true;
  160 + }
  161 + }
  162 + return false;
  163 + }
  164 +
  165 + /// Stops the video feed and analyzer
  166 + Future<void> cancel() async {
  167 + try {
  168 + // Stop the camera stream
  169 + _localStream!.getTracks().forEach((track) {
  170 + if (track.readyState == 'live') {
  171 + track.stop();
  172 + }
  173 + });
  174 + } catch (e) {
  175 + debugPrint('Failed to stop stream: $e');
  176 + }
  177 +
  178 + video.srcObject = null;
  179 + _localStream = null;
  180 + _frameInterval?.cancel();
  181 + _frameInterval = null;
  182 + }
  183 +
  184 + /// Captures a frame and analyzes it for QR codes
  185 + Future<dynamic> _captureFrame() async {
  186 + if (_localStream == null) return null;
  187 + final canvas =
  188 + html.CanvasElement(width: video.videoWidth, height: video.videoHeight);
  189 + final ctx = canvas.context2D;
  190 +
  191 + ctx.drawImage(video, 0, 0);
  192 + final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);
  193 +
  194 + final code = jsQR(imgData.data, canvas.width, canvas.height);
  195 + if (code != null) {
  196 + controller.add({'name': 'barcodeWeb', 'data': code.data});
  197 + }
  198 + }
  199 +}
@@ -2,10 +2,6 @@ import 'package:flutter/foundation.dart'; @@ -2,10 +2,6 @@ import 'package:flutter/foundation.dart';
2 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
3 import 'package:mobile_scanner/mobile_scanner.dart'; 3 import 'package:mobile_scanner/mobile_scanner.dart';
4 4
5 -import 'mobile_scanner_arguments.dart';  
6 -  
7 -import 'web/flutter_qr_web.dart';  
8 -  
9 enum Ratio { ratio_4_3, ratio_16_9 } 5 enum Ratio { ratio_4_3, ratio_16_9 }
10 6
11 /// A widget showing a live camera preview. 7 /// A widget showing a live camera preview.
@@ -64,12 +60,6 @@ class _MobileScannerState extends State<MobileScanner> @@ -64,12 +60,6 @@ class _MobileScannerState extends State<MobileScanner>
64 60
65 @override 61 @override
66 Widget build(BuildContext context) { 62 Widget build(BuildContext context) {
67 - if (kIsWeb) {  
68 - return WebScanner(  
69 - onDetect: (barcode) => widget.onDetect!(barcode, null),  
70 - cameraFacing: CameraFacing.back,  
71 - );  
72 - } else {  
73 return LayoutBuilder(builder: (context, BoxConstraints constraints) { 63 return LayoutBuilder(builder: (context, BoxConstraints constraints) {
74 return ValueListenableBuilder( 64 return ValueListenableBuilder(
75 valueListenable: controller.args, 65 valueListenable: controller.args,
@@ -96,7 +86,7 @@ class _MobileScannerState extends State<MobileScanner> @@ -96,7 +86,7 @@ class _MobileScannerState extends State<MobileScanner>
96 child: SizedBox( 86 child: SizedBox(
97 width: value.size.width, 87 width: value.size.width,
98 height: value.size.height, 88 height: value.size.height,
99 - child: Texture(textureId: value.textureId), 89 + child: kIsWeb ? HtmlElementView(viewType: value.webId!) : Texture(textureId: value.textureId!),
100 ), 90 ),
101 ), 91 ),
102 ), 92 ),
@@ -105,7 +95,6 @@ class _MobileScannerState extends State<MobileScanner> @@ -105,7 +95,6 @@ class _MobileScannerState extends State<MobileScanner>
105 }); 95 });
106 }); 96 });
107 } 97 }
108 - }  
109 98
110 @override 99 @override
111 void didUpdateWidget(covariant MobileScanner oldWidget) { 100 void didUpdateWidget(covariant MobileScanner oldWidget) {
@@ -3,14 +3,16 @@ import 'package:flutter/material.dart'; @@ -3,14 +3,16 @@ import 'package:flutter/material.dart';
3 /// Camera args for [CameraView]. 3 /// Camera args for [CameraView].
4 class MobileScannerArguments { 4 class MobileScannerArguments {
5 /// The texture id. 5 /// The texture id.
6 - final int textureId; 6 + final int? textureId;
7 7
8 /// Size of the texture. 8 /// Size of the texture.
9 final Size size; 9 final Size size;
10 10
11 final bool hasTorch; 11 final bool hasTorch;
12 12
  13 + final String? webId;
  14 +
13 /// Create a [MobileScannerArguments]. 15 /// Create a [MobileScannerArguments].
14 MobileScannerArguments( 16 MobileScannerArguments(
15 - {required this.textureId, required this.size, required this.hasTorch}); 17 + {this.textureId, required this.size, required this.hasTorch, this.webId});
16 } 18 }
@@ -2,6 +2,7 @@ import 'dart:async'; @@ -2,6 +2,7 @@ import 'dart:async';
2 import 'dart:io'; 2 import 'dart:io';
3 3
4 import 'package:flutter/cupertino.dart'; 4 import 'package:flutter/cupertino.dart';
  5 +import 'package:flutter/foundation.dart';
5 import 'package:flutter/services.dart'; 6 import 'package:flutter/services.dart';
6 import 'package:mobile_scanner/mobile_scanner.dart'; 7 import 'package:mobile_scanner/mobile_scanner.dart';
7 8
@@ -98,6 +99,9 @@ class MobileScannerController { @@ -98,6 +99,9 @@ class MobileScannerController {
98 case 'barcodeMac': 99 case 'barcodeMac':
99 barcodesController.add(Barcode(rawValue: data['payload'])); 100 barcodesController.add(Barcode(rawValue: data['payload']));
100 break; 101 break;
  102 + case 'barcodeWeb':
  103 + barcodesController.add(Barcode(rawValue: data));
  104 + break;
101 default: 105 default:
102 throw UnimplementedError(); 106 throw UnimplementedError();
103 } 107 }
@@ -125,19 +129,22 @@ class MobileScannerController { @@ -125,19 +129,22 @@ class MobileScannerController {
125 // setAnalyzeMode(AnalyzeMode.barcode.index); 129 // setAnalyzeMode(AnalyzeMode.barcode.index);
126 130
127 // Check authorization status 131 // Check authorization status
128 - MobileScannerState state =  
129 - MobileScannerState.values[await methodChannel.invokeMethod('state')];  
130 - switch (state) {  
131 - case MobileScannerState.undetermined:  
132 - final bool result = await methodChannel.invokeMethod('request');  
133 - state =  
134 - result ? MobileScannerState.authorized : MobileScannerState.denied;  
135 - break;  
136 - case MobileScannerState.denied:  
137 - isStarting = false;  
138 - throw PlatformException(code: 'NO ACCESS');  
139 - case MobileScannerState.authorized:  
140 - break; 132 + if (!kIsWeb) {
  133 + MobileScannerState state =
  134 + MobileScannerState.values[await methodChannel.invokeMethod('state')];
  135 + switch (state) {
  136 + case MobileScannerState.undetermined:
  137 + final bool result = await methodChannel.invokeMethod('request');
  138 + state = result
  139 + ? MobileScannerState.authorized
  140 + : MobileScannerState.denied;
  141 + break;
  142 + case MobileScannerState.denied:
  143 + isStarting = false;
  144 + throw PlatformException(code: 'NO ACCESS');
  145 + case MobileScannerState.authorized:
  146 + break;
  147 + }
141 } 148 }
142 149
143 cameraFacingState.value = facing; 150 cameraFacingState.value = facing;
@@ -174,10 +181,20 @@ class MobileScannerController { @@ -174,10 +181,20 @@ class MobileScannerController {
174 } 181 }
175 182
176 hasTorch = startResult['torchable']; 183 hasTorch = startResult['torchable'];
177 - args.value = MobileScannerArguments(  
178 - textureId: startResult['textureId'],  
179 - size: toSize(startResult['size']),  
180 - hasTorch: hasTorch); 184 +
  185 + if (kIsWeb) {
  186 + args.value = MobileScannerArguments(
  187 + webId: startResult['ViewID'],
  188 + size: Size(startResult['videoWidth'], startResult['videoHeight']),
  189 + hasTorch: hasTorch);
  190 + } else {
  191 +
  192 + args.value = MobileScannerArguments(
  193 + textureId: startResult['textureId'],
  194 + size: toSize(startResult['size']),
  195 + hasTorch: hasTorch);
  196 + }
  197 +
181 isStarting = false; 198 isStarting = false;
182 } 199 }
183 200
1 -// ignore_for_file: avoid_web_libraries_in_flutter  
2 -  
3 -import 'dart:async';  
4 -import 'dart:core';  
5 -import 'dart:html' as html;  
6 -import 'dart:ui' as ui;  
7 -  
8 -import 'package:flutter/material.dart';  
9 -  
10 -import '../../mobile_scanner.dart';  
11 -import 'jsqr.dart';  
12 -import 'media.dart';  
13 -  
14 -/// Even though it has been highly modified, the origial implementation has been  
15 -/// adopted from https://github.com:treeder/jsqr_flutter  
16 -///  
17 -/// Copyright 2020 @treeder  
18 -/// Copyright 2021 The one with the braid  
19 -  
20 -class WebScanner extends StatefulWidget {  
21 - final Function(Barcode) onDetect;  
22 - final CameraFacing? cameraFacing;  
23 -  
24 - const WebScanner(  
25 - {Key? key,  
26 - required this.onDetect,  
27 - this.cameraFacing = CameraFacing.front})  
28 - : super(key: key);  
29 -  
30 - @override  
31 - _WebScannerState createState() => _WebScannerState();  
32 -  
33 - // need a global for the registerViewFactory  
34 - static html.DivElement vidDiv = html.DivElement();  
35 -  
36 - static Future<bool> cameraAvailable() async {  
37 - final sources =  
38 - await html.window.navigator.mediaDevices!.enumerateDevices();  
39 - // List<String> vidIds = [];  
40 - var hasCam = false;  
41 - for (final e in sources) {  
42 - if (e.kind == 'videoinput') {  
43 - // vidIds.add(e['deviceId']);  
44 - hasCam = true;  
45 - }  
46 - }  
47 - return hasCam;  
48 - }  
49 -}  
50 -  
51 -class _WebScannerState extends State<WebScanner> {  
52 - // Which way the camera is facing  
53 - // late CameraFacing facing;  
54 -  
55 - // The camera stream to display to the user  
56 - html.MediaStream? _localStream;  
57 -  
58 - // Check if analyzer is processing barcode  
59 - bool _currentlyProcessing = false;  
60 -  
61 - // QRViewControllerWeb? _controller;  
62 -  
63 - // Set size of the webview  
64 - // Size _size = const Size(0, 0);  
65 -  
66 - // TODO: Timer for capture?  
67 - Timer? timer;  
68 -  
69 - // String? code;  
70 -  
71 - // TODO: Error message if error  
72 - String? _errorMsg;  
73 -  
74 - // Video element to be played on  
75 - html.VideoElement video = html.VideoElement();  
76 -  
77 - // ID of the video feed  
78 - String viewID =  
79 - 'WebScanner-' + DateTime.now().millisecondsSinceEpoch.toString();  
80 -  
81 - // final StreamController<Barcode> _scanUpdateController =  
82 - // StreamController<Barcode>();  
83 -  
84 - // Timer for interval capture  
85 - Timer? _frameIntervall;  
86 -  
87 - @override  
88 - void initState() {  
89 - super.initState();  
90 - // facing = widget.cameraFacing ?? CameraFacing.front;  
91 - WebScanner.vidDiv.children = [video];  
92 -  
93 - // ignore: UNDEFINED_PREFIXED_NAME  
94 - ui.platformViewRegistry  
95 - .registerViewFactory(viewID, (int id) => WebScanner.vidDiv);  
96 -  
97 - // giving JavaScipt some time to process the DOM changes  
98 - Timer(const Duration(milliseconds: 500), () {  
99 - start();  
100 - });  
101 - }  
102 -  
103 - /// Initialize camera and capture frame  
104 - Future start() async {  
105 - await _startVideoStream();  
106 - _frameIntervall?.cancel();  
107 - _frameIntervall =  
108 - Timer.periodic(const Duration(milliseconds: 200), (timer) {  
109 - _captureFrame();  
110 - });  
111 - }  
112 -  
113 - void cancel() {  
114 - if (timer != null) {  
115 - timer!.cancel();  
116 - timer = null;  
117 - }  
118 - if (_currentlyProcessing) {  
119 - _stopVideoStream();  
120 - }  
121 - }  
122 -  
123 - @override  
124 - void dispose() {  
125 - cancel();  
126 - super.dispose();  
127 - }  
128 -  
129 - /// Starts a video stream if not started already  
130 - Future<void> _startVideoStream() async {  
131 - // Check if stream is running  
132 - if (_localStream != null) return;  
133 -  
134 - try {  
135 - // Check if browser supports multiple camera's and set if supported  
136 - Map? capabilities =  
137 - html.window.navigator.mediaDevices?.getSupportedConstraints();  
138 - if (capabilities != null && capabilities['facingMode']) {  
139 - UserMediaOptions constraints = UserMediaOptions(  
140 - video: VideoOptions(  
141 - facingMode: (widget.cameraFacing == CameraFacing.front  
142 - ? 'user'  
143 - : 'environment'),  
144 - width: {'ideal': 4096},  
145 - height: {'ideal': 2160},  
146 - ));  
147 -  
148 - _localStream =  
149 - await html.window.navigator.getUserMedia(video: constraints);  
150 - } else {  
151 - _localStream = await html.window.navigator.getUserMedia(video: true);  
152 - }  
153 -  
154 - video.srcObject = _localStream;  
155 -  
156 - // required to tell iOS safari we don't want fullscreen  
157 - video.setAttribute('playsinline', 'true');  
158 -  
159 - // TODO: Check controller  
160 - // if (_controller == null) {  
161 - // _controller = QRViewControllerWeb(this);  
162 - // widget.onPlatformViewCreated(_controller!);  
163 - // }  
164 -  
165 - await video.play();  
166 - } catch (e) {  
167 - cancel();  
168 - setState(() {  
169 - _errorMsg = e.toString();  
170 - });  
171 - return;  
172 - }  
173 -  
174 - if (!mounted) return;  
175 -  
176 - setState(() {  
177 - _currentlyProcessing = true;  
178 - });  
179 - }  
180 -  
181 - Future<void> _stopVideoStream() async {  
182 - try {  
183 - // Stop the camera stream  
184 - _localStream!.getTracks().forEach((track) {  
185 - if (track.readyState == 'live') {  
186 - track.stop();  
187 - }  
188 - });  
189 -  
190 - video.srcObject = null;  
191 - _localStream = null;  
192 - } catch (e) {  
193 - debugPrint('Failed to stop stream: $e');  
194 - }  
195 - }  
196 -  
197 - Future<dynamic> _captureFrame() async {  
198 - if (_localStream == null) return null;  
199 - final canvas = html.CanvasElement(width: video.videoWidth, height: video.videoHeight);  
200 - final ctx = canvas.context2D;  
201 -  
202 - ctx.drawImage(video, 0, 0);  
203 - final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);  
204 -  
205 - // final size =  
206 - // Size(canvas.width?.toDouble() ?? 0, canvas.height?.toDouble() ?? 0);  
207 - // if (size != _size) {  
208 - // setState(() {  
209 - // _setCanvasSize(size);  
210 - // });  
211 - // }  
212 - // debugPrint('img.data: ${imgData.data}');  
213 - final code = jsQR(imgData.data, canvas.width, canvas.height);  
214 - // ignore: unnecessary_null_comparison  
215 - if (code != null) {  
216 - debugPrint('CODE: $code');  
217 - // widget.onDetect(Barcode(rawValue: code.data));  
218 - // print('Barcode: ${code.data}');  
219 - // _scanUpdateController  
220 - // .add(Barcode(rawValue: code.data));  
221 - }  
222 - }  
223 -  
224 - @override  
225 - Widget build(BuildContext context) {  
226 - if (_errorMsg != null) {  
227 - return Center(child: Text(_errorMsg!));  
228 - }  
229 - if (_localStream == null) {  
230 - return const Center(child: CircularProgressIndicator());  
231 - }  
232 -  
233 - return SizedBox(  
234 - width: MediaQuery.of(context).size.width,  
235 - height: MediaQuery.of(context).size.height,  
236 - child: FittedBox(  
237 - child: SizedBox(  
238 - width: video.videoWidth.toDouble(),  
239 - height: video.videoHeight.toDouble(),  
240 - child: HtmlElementView(viewType: viewID))));  
241 - }  
242 -}  
@@ -4,7 +4,7 @@ library jsqr; @@ -4,7 +4,7 @@ library jsqr;
4 import 'package:js/js.dart'; 4 import 'package:js/js.dart';
5 5
6 @JS('jsQR') 6 @JS('jsQR')
7 -external Code jsQR(var data, int? width, int? height); 7 +external Code? jsQR(var data, int? width, int? height);
8 8
9 @JS() 9 @JS()
10 class Code { 10 class Code {
@@ -11,6 +11,8 @@ dependencies: @@ -11,6 +11,8 @@ dependencies:
11 js: ^0.6.4 11 js: ^0.6.4
12 flutter: 12 flutter:
13 sdk: flutter 13 sdk: flutter
  14 + flutter_web_plugins:
  15 + sdk: flutter
14 16
15 dev_dependencies: 17 dev_dependencies:
16 flutter_test: 18 flutter_test:
@@ -26,4 +28,7 @@ flutter: @@ -26,4 +28,7 @@ flutter:
26 ios: 28 ios:
27 pluginClass: MobileScannerPlugin 29 pluginClass: MobileScannerPlugin
28 macos: 30 macos:
29 - pluginClass: MobileScannerPlugin  
  31 + pluginClass: MobileScannerPlugin
  32 + web:
  33 + pluginClass: MobileScannerWebPlugin
  34 + fileName: mobile_scanner_web_plugin.dart