Committed by
GitHub
Merge branch 'master' into feature/zoom
Showing
15 changed files
with
544 additions
and
160 deletions
| @@ -7,7 +7,7 @@ | @@ -7,7 +7,7 @@ | ||
| 7 | release-please: | 7 | release-please: |
| 8 | runs-on: ubuntu-latest | 8 | runs-on: ubuntu-latest |
| 9 | steps: | 9 | steps: |
| 10 | - - uses: GoogleCloudPlatform/release-please-action@v3.6.0 | 10 | + - uses: GoogleCloudPlatform/release-please-action@v3.6.1 |
| 11 | with: | 11 | with: |
| 12 | token: ${{ secrets.GITHUB_TOKEN }} | 12 | token: ${{ secrets.GITHUB_TOKEN }} |
| 13 | release-type: simple | 13 | release-type: simple |
| @@ -45,12 +45,9 @@ Ensure that you granted camera permission in XCode -> Signing & Capabilities: | @@ -45,12 +45,9 @@ Ensure that you granted camera permission in XCode -> Signing & Capabilities: | ||
| 45 | Add this to `web/index.html`: | 45 | Add this to `web/index.html`: |
| 46 | 46 | ||
| 47 | ```html | 47 | ```html |
| 48 | -<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script> | 48 | +<script type="text/javascript" src="https://unpkg.com/@zxing/library@0.19.1"></script> |
| 49 | ``` | 49 | ``` |
| 50 | 50 | ||
| 51 | -Web only supports QR codes for now. | ||
| 52 | -Do you have experience with Flutter Web development? [Help me with migrating from jsQR to qr-scanner for full barcode support!](https://github.com/juliansteenbakker/mobile_scanner/issues/54) | ||
| 53 | - | ||
| 54 | ## Features Supported | 51 | ## Features Supported |
| 55 | 52 | ||
| 56 | | Features | Android | iOS | macOS | Web | | 53 | | Features | Android | iOS | macOS | Web | |
| @@ -2,7 +2,7 @@ group 'dev.steenbakker.mobile_scanner' | @@ -2,7 +2,7 @@ group 'dev.steenbakker.mobile_scanner' | ||
| 2 | version '1.0-SNAPSHOT' | 2 | version '1.0-SNAPSHOT' |
| 3 | 3 | ||
| 4 | buildscript { | 4 | buildscript { |
| 5 | - ext.kotlin_version = '1.7.21' | 5 | + ext.kotlin_version = '1.7.22' |
| 6 | repositories { | 6 | repositories { |
| 7 | google() | 7 | google() |
| 8 | mavenCentral() | 8 | mavenCentral() |
| @@ -77,33 +77,41 @@ class _BarcodeScannerWithControllerState | @@ -77,33 +77,41 @@ class _BarcodeScannerWithControllerState | ||
| 77 | child: Row( | 77 | child: Row( |
| 78 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, | 78 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
| 79 | children: [ | 79 | children: [ |
| 80 | - IconButton( | ||
| 81 | - color: Colors.white, | ||
| 82 | - icon: ValueListenableBuilder( | ||
| 83 | - valueListenable: controller.torchState, | ||
| 84 | - builder: (context, state, child) { | ||
| 85 | - if (state == null) { | ||
| 86 | - return const Icon( | ||
| 87 | - Icons.flash_off, | ||
| 88 | - color: Colors.grey, | ||
| 89 | - ); | ||
| 90 | - } | ||
| 91 | - switch (state as TorchState) { | ||
| 92 | - case TorchState.off: | ||
| 93 | - return const Icon( | ||
| 94 | - Icons.flash_off, | ||
| 95 | - color: Colors.grey, | ||
| 96 | - ); | ||
| 97 | - case TorchState.on: | ||
| 98 | - return const Icon( | ||
| 99 | - Icons.flash_on, | ||
| 100 | - color: Colors.yellow, | ||
| 101 | - ); | ||
| 102 | - } | ||
| 103 | - }, | ||
| 104 | - ), | ||
| 105 | - iconSize: 32.0, | ||
| 106 | - onPressed: () => controller.toggleTorch(), | 80 | + ValueListenableBuilder( |
| 81 | + valueListenable: controller.hasTorchState, | ||
| 82 | + builder: (context, state, child) { | ||
| 83 | + if (state != true) { | ||
| 84 | + return const SizedBox.shrink(); | ||
| 85 | + } | ||
| 86 | + return IconButton( | ||
| 87 | + color: Colors.white, | ||
| 88 | + icon: ValueListenableBuilder( | ||
| 89 | + valueListenable: controller.torchState, | ||
| 90 | + builder: (context, state, child) { | ||
| 91 | + if (state == null) { | ||
| 92 | + return const Icon( | ||
| 93 | + Icons.flash_off, | ||
| 94 | + color: Colors.grey, | ||
| 95 | + ); | ||
| 96 | + } | ||
| 97 | + switch (state as TorchState) { | ||
| 98 | + case TorchState.off: | ||
| 99 | + return const Icon( | ||
| 100 | + Icons.flash_off, | ||
| 101 | + color: Colors.grey, | ||
| 102 | + ); | ||
| 103 | + case TorchState.on: | ||
| 104 | + return const Icon( | ||
| 105 | + Icons.flash_on, | ||
| 106 | + color: Colors.yellow, | ||
| 107 | + ); | ||
| 108 | + } | ||
| 109 | + }, | ||
| 110 | + ), | ||
| 111 | + iconSize: 32.0, | ||
| 112 | + onPressed: () => controller.toggleTorch(), | ||
| 113 | + ); | ||
| 114 | + }, | ||
| 107 | ), | 115 | ), |
| 108 | IconButton( | 116 | IconButton( |
| 109 | color: Colors.white, | 117 | color: Colors.white, |
| @@ -28,8 +28,8 @@ | @@ -28,8 +28,8 @@ | ||
| 28 | 28 | ||
| 29 | <title>example</title> | 29 | <title>example</title> |
| 30 | <link rel="manifest" href="manifest.json"> | 30 | <link rel="manifest" href="manifest.json"> |
| 31 | -<!-- <script src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.min.js"></script>--> | ||
| 32 | <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script> | 31 | <script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script> |
| 32 | + <script type="text/javascript" src="https://unpkg.com/@zxing/library@0.19.1"></script> | ||
| 33 | </head> | 33 | </head> |
| 34 | <body> | 34 | <body> |
| 35 | <!-- This script installs service_worker.js to provide PWA functionality to | 35 | <!-- This script installs service_worker.js to provide PWA functionality to |
lib/mobile_scanner_web.dart
0 → 100644
| @@ -2,12 +2,10 @@ import 'dart:async'; | @@ -2,12 +2,10 @@ import 'dart:async'; | ||
| 2 | import 'dart:html' as html; | 2 | import 'dart:html' as html; |
| 3 | import 'dart:ui' as ui; | 3 | import 'dart:ui' as ui; |
| 4 | 4 | ||
| 5 | -import 'package:flutter/material.dart'; | ||
| 6 | import 'package:flutter/services.dart'; | 5 | import 'package:flutter/services.dart'; |
| 7 | import 'package:flutter_web_plugins/flutter_web_plugins.dart'; | 6 | import 'package:flutter_web_plugins/flutter_web_plugins.dart'; |
| 7 | +import 'package:mobile_scanner/mobile_scanner_web.dart'; | ||
| 8 | import 'package:mobile_scanner/src/enums/camera_facing.dart'; | 8 | import 'package:mobile_scanner/src/enums/camera_facing.dart'; |
| 9 | -import 'package:mobile_scanner/src/web/jsqr.dart'; | ||
| 10 | -import 'package:mobile_scanner/src/web/media.dart'; | ||
| 11 | 9 | ||
| 12 | /// This plugin is the web implementation of mobile_scanner. | 10 | /// This plugin is the web implementation of mobile_scanner. |
| 13 | /// It only supports QR codes. | 11 | /// It only supports QR codes. |
| @@ -32,20 +30,14 @@ class MobileScannerWebPlugin { | @@ -32,20 +30,14 @@ class MobileScannerWebPlugin { | ||
| 32 | // Controller to send events back to the framework | 30 | // Controller to send events back to the framework |
| 33 | StreamController controller = StreamController.broadcast(); | 31 | StreamController controller = StreamController.broadcast(); |
| 34 | 32 | ||
| 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 | 33 | // ID of the video feed |
| 40 | String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}'; | 34 | String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}'; |
| 41 | 35 | ||
| 42 | - // Determine wether device has flas | ||
| 43 | - bool hasFlash = false; | ||
| 44 | - | ||
| 45 | - // Timer used to capture frames to be analyzed | ||
| 46 | - Timer? _frameInterval; | 36 | + static final html.DivElement vidDiv = html.DivElement(); |
| 47 | 37 | ||
| 48 | - html.DivElement vidDiv = html.DivElement(); | 38 | + static WebBarcodeReaderBase barCodeReader = |
| 39 | + ZXingBarcodeReader(videoContainer: vidDiv); | ||
| 40 | + StreamSubscription? _barCodeStreamSubscription; | ||
| 49 | 41 | ||
| 50 | /// Handle incomming messages | 42 | /// Handle incomming messages |
| 51 | Future<dynamic> handleMethodCall(MethodCall call) async { | 43 | Future<dynamic> handleMethodCall(MethodCall call) async { |
| @@ -67,20 +59,11 @@ class MobileScannerWebPlugin { | @@ -67,20 +59,11 @@ class MobileScannerWebPlugin { | ||
| 67 | 59 | ||
| 68 | /// Can enable or disable the flash if available | 60 | /// Can enable or disable the flash if available |
| 69 | Future<void> _torch(arguments) async { | 61 | Future<void> _torch(arguments) async { |
| 70 | - if (hasFlash) { | ||
| 71 | - final track = _localStream?.getVideoTracks(); | ||
| 72 | - await track!.first.applyConstraints({ | ||
| 73 | - 'advanced': {'torch': arguments == 1} | ||
| 74 | - }); | ||
| 75 | - } else { | ||
| 76 | - controller.addError('Device has no flash'); | ||
| 77 | - } | 62 | + barCodeReader.toggleTorch(enabled: arguments == 1); |
| 78 | } | 63 | } |
| 79 | 64 | ||
| 80 | /// Starts the video stream and the scanner | 65 | /// Starts the video stream and the scanner |
| 81 | Future<Map> _start(Map arguments) async { | 66 | Future<Map> _start(Map arguments) async { |
| 82 | - vidDiv.children = [video]; | ||
| 83 | - | ||
| 84 | var cameraFacing = CameraFacing.front; | 67 | var cameraFacing = CameraFacing.front; |
| 85 | if (arguments.containsKey('facing')) { | 68 | if (arguments.containsKey('facing')) { |
| 86 | cameraFacing = CameraFacing.values[arguments['facing'] as int]; | 69 | cameraFacing = CameraFacing.values[arguments['facing'] as int]; |
| @@ -90,64 +73,51 @@ class MobileScannerWebPlugin { | @@ -90,64 +73,51 @@ class MobileScannerWebPlugin { | ||
| 90 | // ignore: UNDEFINED_PREFIXED_NAME, avoid_dynamic_calls | 73 | // ignore: UNDEFINED_PREFIXED_NAME, avoid_dynamic_calls |
| 91 | ui.platformViewRegistry.registerViewFactory( | 74 | ui.platformViewRegistry.registerViewFactory( |
| 92 | viewID, | 75 | viewID, |
| 93 | - (int id) => vidDiv | ||
| 94 | - ..style.width = '100%' | ||
| 95 | - ..style.height = '100%', | 76 | + (int id) { |
| 77 | + return vidDiv | ||
| 78 | + ..style.width = '100%' | ||
| 79 | + ..style.height = '100%'; | ||
| 80 | + }, | ||
| 96 | ); | 81 | ); |
| 97 | 82 | ||
| 98 | // Check if stream is running | 83 | // Check if stream is running |
| 99 | - if (_localStream != null) { | 84 | + if (barCodeReader.isStarted) { |
| 85 | + final hasTorch = await barCodeReader.hasTorch(); | ||
| 100 | return { | 86 | return { |
| 101 | 'ViewID': viewID, | 87 | 'ViewID': viewID, |
| 102 | - 'videoWidth': video.videoWidth, | ||
| 103 | - 'videoHeight': video.videoHeight | 88 | + 'videoWidth': barCodeReader.videoWidth, |
| 89 | + 'videoHeight': barCodeReader.videoHeight, | ||
| 90 | + 'torchable': hasTorch, | ||
| 104 | }; | 91 | }; |
| 105 | } | 92 | } |
| 106 | - | ||
| 107 | try { | 93 | try { |
| 108 | - // Check if browser supports multiple camera's and set if supported | ||
| 109 | - final Map? capabilities = | ||
| 110 | - html.window.navigator.mediaDevices?.getSupportedConstraints(); | ||
| 111 | - if (capabilities != null && capabilities['facingMode'] as bool) { | ||
| 112 | - final constraints = { | ||
| 113 | - 'video': VideoOptions( | ||
| 114 | - facingMode: | ||
| 115 | - cameraFacing == CameraFacing.front ? 'user' : 'environment', | ||
| 116 | - ) | ||
| 117 | - }; | ||
| 118 | - | ||
| 119 | - _localStream = | ||
| 120 | - await html.window.navigator.mediaDevices?.getUserMedia(constraints); | ||
| 121 | - } else { | ||
| 122 | - _localStream = await html.window.navigator.mediaDevices | ||
| 123 | - ?.getUserMedia({'video': true}); | ||
| 124 | - } | ||
| 125 | - | ||
| 126 | - video.srcObject = _localStream; | ||
| 127 | - | ||
| 128 | - // TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533 | ||
| 129 | - // final track = _localStream?.getVideoTracks(); | ||
| 130 | - // if (track != null) { | ||
| 131 | - // final imageCapture = html.ImageCapture(track.first); | ||
| 132 | - // final photoCapabilities = await imageCapture.getPhotoCapabilities(); | ||
| 133 | - // } | ||
| 134 | - | ||
| 135 | - // required to tell iOS safari we don't want fullscreen | ||
| 136 | - video.setAttribute('playsinline', 'true'); | ||
| 137 | - | ||
| 138 | - await video.play(); | ||
| 139 | - | ||
| 140 | - // Then capture a frame to be analyzed every 200 miliseconds | ||
| 141 | - _frameInterval = | ||
| 142 | - Timer.periodic(const Duration(milliseconds: 200), (timer) { | ||
| 143 | - _captureFrame(); | 94 | + await barCodeReader.start( |
| 95 | + cameraFacing: cameraFacing, | ||
| 96 | + ); | ||
| 97 | + | ||
| 98 | + _barCodeStreamSubscription = | ||
| 99 | + barCodeReader.detectBarcodeContinuously().listen((code) { | ||
| 100 | + if (code != null) { | ||
| 101 | + controller.add({ | ||
| 102 | + 'name': 'barcodeWeb', | ||
| 103 | + 'data': { | ||
| 104 | + 'rawValue': code.rawValue, | ||
| 105 | + 'rawBytes': code.rawBytes, | ||
| 106 | + }, | ||
| 107 | + }); | ||
| 108 | + } | ||
| 144 | }); | 109 | }); |
| 110 | + final hasTorch = await barCodeReader.hasTorch(); | ||
| 111 | + | ||
| 112 | + if (hasTorch && arguments.containsKey('torch')) { | ||
| 113 | + barCodeReader.toggleTorch(enabled: arguments['torch'] as bool); | ||
| 114 | + } | ||
| 145 | 115 | ||
| 146 | return { | 116 | return { |
| 147 | 'ViewID': viewID, | 117 | 'ViewID': viewID, |
| 148 | - 'videoWidth': video.videoWidth, | ||
| 149 | - 'videoHeight': video.videoHeight, | ||
| 150 | - 'torchable': hasFlash | 118 | + 'videoWidth': barCodeReader.videoWidth, |
| 119 | + 'videoHeight': barCodeReader.videoHeight, | ||
| 120 | + 'torchable': hasTorch, | ||
| 151 | }; | 121 | }; |
| 152 | } catch (e) { | 122 | } catch (e) { |
| 153 | throw PlatformException(code: 'MobileScannerWeb', message: '$e'); | 123 | throw PlatformException(code: 'MobileScannerWeb', message: '$e'); |
| @@ -170,40 +140,8 @@ class MobileScannerWebPlugin { | @@ -170,40 +140,8 @@ class MobileScannerWebPlugin { | ||
| 170 | 140 | ||
| 171 | /// Stops the video feed and analyzer | 141 | /// Stops the video feed and analyzer |
| 172 | Future<void> cancel() async { | 142 | Future<void> cancel() async { |
| 173 | - try { | ||
| 174 | - // Stop the camera stream | ||
| 175 | - _localStream?.getTracks().forEach((track) { | ||
| 176 | - if (track.readyState == 'live') { | ||
| 177 | - track.stop(); | ||
| 178 | - } | ||
| 179 | - }); | ||
| 180 | - } catch (e) { | ||
| 181 | - debugPrint('Failed to stop stream: $e'); | ||
| 182 | - } | ||
| 183 | - | ||
| 184 | - video.srcObject = null; | ||
| 185 | - _localStream = null; | ||
| 186 | - _frameInterval?.cancel(); | ||
| 187 | - _frameInterval = null; | ||
| 188 | - } | ||
| 189 | - | ||
| 190 | - /// Captures a frame and analyzes it for QR codes | ||
| 191 | - Future<dynamic> _captureFrame() async { | ||
| 192 | - if (_localStream == null) return null; | ||
| 193 | - final canvas = | ||
| 194 | - html.CanvasElement(width: video.videoWidth, height: video.videoHeight); | ||
| 195 | - final ctx = canvas.context2D; | ||
| 196 | - | ||
| 197 | - ctx.drawImage(video, 0, 0); | ||
| 198 | - final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!); | ||
| 199 | - | ||
| 200 | - final code = jsQR(imgData.data, canvas.width, canvas.height); | ||
| 201 | - if (code != null) { | ||
| 202 | - controller.add({ | ||
| 203 | - 'name': 'barcodeWeb', | ||
| 204 | - 'data': code.data, | ||
| 205 | - 'binaryData': code.binaryData, | ||
| 206 | - }); | ||
| 207 | - } | 143 | + barCodeReader.stop(); |
| 144 | + await _barCodeStreamSubscription?.cancel(); | ||
| 145 | + _barCodeStreamSubscription = null; | ||
| 208 | } | 146 | } |
| 209 | } | 147 | } |
| @@ -99,19 +99,21 @@ class MobileScannerController { | @@ -99,19 +99,21 @@ class MobileScannerController { | ||
| 99 | 99 | ||
| 100 | bool isStarting = false; | 100 | bool isStarting = false; |
| 101 | 101 | ||
| 102 | - bool? _hasTorch; | 102 | + /// A notifier that provides availability of the Torch (Flash) |
| 103 | + final ValueNotifier<bool?> hasTorchState = ValueNotifier(false); | ||
| 103 | 104 | ||
| 104 | /// Returns whether the device has a torch. | 105 | /// Returns whether the device has a torch. |
| 105 | /// | 106 | /// |
| 106 | /// Throws an error if the controller is not initialized. | 107 | /// Throws an error if the controller is not initialized. |
| 107 | bool get hasTorch { | 108 | bool get hasTorch { |
| 108 | - if (_hasTorch == null) { | 109 | + final hasTorch = hasTorchState.value; |
| 110 | + if (hasTorch == null) { | ||
| 109 | throw const MobileScannerException( | 111 | throw const MobileScannerException( |
| 110 | errorCode: MobileScannerErrorCode.controllerUninitialized, | 112 | errorCode: MobileScannerErrorCode.controllerUninitialized, |
| 111 | ); | 113 | ); |
| 112 | } | 114 | } |
| 113 | 115 | ||
| 114 | - return _hasTorch!; | 116 | + return hasTorch; |
| 115 | } | 117 | } |
| 116 | 118 | ||
| 117 | /// Set the starting arguments for the camera | 119 | /// Set the starting arguments for the camera |
| @@ -210,8 +212,9 @@ class MobileScannerController { | @@ -210,8 +212,9 @@ class MobileScannerController { | ||
| 210 | ); | 212 | ); |
| 211 | } | 213 | } |
| 212 | 214 | ||
| 213 | - _hasTorch = startResult['torchable'] as bool? ?? false; | ||
| 214 | - if (_hasTorch! && torchEnabled) { | 215 | + final hasTorch = startResult['torchable'] as bool? ?? false; |
| 216 | + hasTorchState.value = hasTorch; | ||
| 217 | + if (hasTorch && torchEnabled) { | ||
| 215 | torchState.value = TorchState.on; | 218 | torchState.value = TorchState.on; |
| 216 | } | 219 | } |
| 217 | 220 | ||
| @@ -223,7 +226,7 @@ class MobileScannerController { | @@ -223,7 +226,7 @@ class MobileScannerController { | ||
| 223 | startResult['videoHeight'] as double? ?? 0, | 226 | startResult['videoHeight'] as double? ?? 0, |
| 224 | ) | 227 | ) |
| 225 | : toSize(startResult['size'] as Map? ?? {}), | 228 | : toSize(startResult['size'] as Map? ?? {}), |
| 226 | - hasTorch: _hasTorch!, | 229 | + hasTorch: hasTorch, |
| 227 | textureId: kIsWeb ? null : startResult['textureId'] as int?, | 230 | textureId: kIsWeb ? null : startResult['textureId'] as int?, |
| 228 | webId: kIsWeb ? startResult['ViewID'] as String? : null, | 231 | webId: kIsWeb ? startResult['ViewID'] as String? : null, |
| 229 | ); | 232 | ); |
| @@ -244,7 +247,7 @@ class MobileScannerController { | @@ -244,7 +247,7 @@ class MobileScannerController { | ||
| 244 | /// | 247 | /// |
| 245 | /// Throws if the controller was not initialized. | 248 | /// Throws if the controller was not initialized. |
| 246 | Future<void> toggleTorch() async { | 249 | Future<void> toggleTorch() async { |
| 247 | - final hasTorch = _hasTorch; | 250 | + final hasTorch = hasTorchState.value; |
| 248 | 251 | ||
| 249 | if (hasTorch == null) { | 252 | if (hasTorch == null) { |
| 250 | throw const MobileScannerException( | 253 | throw const MobileScannerException( |
| @@ -342,11 +345,13 @@ class MobileScannerController { | @@ -342,11 +345,13 @@ class MobileScannerController { | ||
| 342 | ); | 345 | ); |
| 343 | break; | 346 | break; |
| 344 | case 'barcodeWeb': | 347 | case 'barcodeWeb': |
| 348 | + final barcode = data as Map?; | ||
| 345 | _barcodesController.add( | 349 | _barcodesController.add( |
| 346 | BarcodeCapture( | 350 | BarcodeCapture( |
| 347 | barcodes: [ | 351 | barcodes: [ |
| 348 | Barcode( | 352 | Barcode( |
| 349 | - rawValue: data as String?, | 353 | + rawValue: barcode?['rawValue'] as String?, |
| 354 | + rawBytes: barcode?['rawBytes'] as Uint8List?, | ||
| 350 | ) | 355 | ) |
| 351 | ], | 356 | ], |
| 352 | ), | 357 | ), |
| @@ -12,6 +12,14 @@ class MobileScannerException implements Exception { | @@ -12,6 +12,14 @@ class MobileScannerException implements Exception { | ||
| 12 | 12 | ||
| 13 | /// The additional error details that came with the [errorCode]. | 13 | /// The additional error details that came with the [errorCode]. |
| 14 | final MobileScannerErrorDetails? errorDetails; | 14 | final MobileScannerErrorDetails? errorDetails; |
| 15 | + | ||
| 16 | + @override | ||
| 17 | + String toString() { | ||
| 18 | + if (errorDetails != null && errorDetails?.message != null) { | ||
| 19 | + return "MobileScannerException: code ${errorCode.name}, message: ${errorDetails?.message}"; | ||
| 20 | + } | ||
| 21 | + return "MobileScannerException: ${errorCode.name}"; | ||
| 22 | + } | ||
| 15 | } | 23 | } |
| 16 | 24 | ||
| 17 | /// The raw error details for a [MobileScannerException]. | 25 | /// The raw error details for a [MobileScannerException]. |
lib/src/web/base.dart
0 → 100644
| 1 | +import 'dart:html' as html; | ||
| 2 | + | ||
| 3 | +import 'package:flutter/material.dart'; | ||
| 4 | +import 'package:js/js.dart'; | ||
| 5 | +import 'package:js/js_util.dart'; | ||
| 6 | +import 'package:mobile_scanner/src/enums/camera_facing.dart'; | ||
| 7 | +import 'package:mobile_scanner/src/objects/barcode.dart'; | ||
| 8 | +import 'package:mobile_scanner/src/web/media.dart'; | ||
| 9 | + | ||
| 10 | +abstract class WebBarcodeReaderBase { | ||
| 11 | + /// Timer used to capture frames to be analyzed | ||
| 12 | + final Duration frameInterval; | ||
| 13 | + final html.DivElement videoContainer; | ||
| 14 | + | ||
| 15 | + const WebBarcodeReaderBase({ | ||
| 16 | + required this.videoContainer, | ||
| 17 | + this.frameInterval = const Duration(milliseconds: 200), | ||
| 18 | + }); | ||
| 19 | + | ||
| 20 | + bool get isStarted; | ||
| 21 | + | ||
| 22 | + int get videoWidth; | ||
| 23 | + int get videoHeight; | ||
| 24 | + | ||
| 25 | + /// Starts streaming video | ||
| 26 | + Future<void> start({ | ||
| 27 | + required CameraFacing cameraFacing, | ||
| 28 | + }); | ||
| 29 | + | ||
| 30 | + /// Starts scanning QR codes or barcodes | ||
| 31 | + Stream<Barcode?> detectBarcodeContinuously(); | ||
| 32 | + | ||
| 33 | + /// Stops streaming video | ||
| 34 | + Future<void> stop(); | ||
| 35 | + | ||
| 36 | + /// Can enable or disable the flash if available | ||
| 37 | + Future<void> toggleTorch({required bool enabled}); | ||
| 38 | + | ||
| 39 | + /// Determine whether device has flash | ||
| 40 | + Future<bool> hasTorch(); | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +mixin InternalStreamCreation on WebBarcodeReaderBase { | ||
| 44 | + /// The video stream. | ||
| 45 | + /// Will be initialized later to see which camera needs to be used. | ||
| 46 | + html.MediaStream? localMediaStream; | ||
| 47 | + final html.VideoElement video = html.VideoElement(); | ||
| 48 | + | ||
| 49 | + @override | ||
| 50 | + int get videoWidth => video.videoWidth; | ||
| 51 | + @override | ||
| 52 | + int get videoHeight => video.videoHeight; | ||
| 53 | + | ||
| 54 | + Future<html.MediaStream?> initMediaStream(CameraFacing cameraFacing) async { | ||
| 55 | + // Check if browser supports multiple camera's and set if supported | ||
| 56 | + final Map? capabilities = | ||
| 57 | + html.window.navigator.mediaDevices?.getSupportedConstraints(); | ||
| 58 | + final Map<String, dynamic> constraints; | ||
| 59 | + if (capabilities != null && capabilities['facingMode'] as bool) { | ||
| 60 | + constraints = { | ||
| 61 | + 'video': VideoOptions( | ||
| 62 | + facingMode: | ||
| 63 | + cameraFacing == CameraFacing.front ? 'user' : 'environment', | ||
| 64 | + ) | ||
| 65 | + }; | ||
| 66 | + } else { | ||
| 67 | + constraints = {'video': true}; | ||
| 68 | + } | ||
| 69 | + final stream = | ||
| 70 | + await html.window.navigator.mediaDevices?.getUserMedia(constraints); | ||
| 71 | + return stream; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + void prepareVideoElement(html.VideoElement videoSource); | ||
| 75 | + | ||
| 76 | + Future<void> attachStreamToVideo( | ||
| 77 | + html.MediaStream stream, | ||
| 78 | + html.VideoElement videoSource, | ||
| 79 | + ); | ||
| 80 | + | ||
| 81 | + @override | ||
| 82 | + Future<void> stop() async { | ||
| 83 | + try { | ||
| 84 | + // Stop the camera stream | ||
| 85 | + localMediaStream?.getTracks().forEach((track) { | ||
| 86 | + if (track.readyState == 'live') { | ||
| 87 | + track.stop(); | ||
| 88 | + } | ||
| 89 | + }); | ||
| 90 | + } catch (e) { | ||
| 91 | + debugPrint('Failed to stop stream: $e'); | ||
| 92 | + } | ||
| 93 | + video.srcObject = null; | ||
| 94 | + localMediaStream = null; | ||
| 95 | + videoContainer.children = []; | ||
| 96 | + } | ||
| 97 | +} | ||
| 98 | + | ||
| 99 | +/// Mixin for libraries that don't have built-in torch support | ||
| 100 | +mixin InternalTorchDetection on InternalStreamCreation { | ||
| 101 | + Future<List<String>> getSupportedTorchStates() async { | ||
| 102 | + try { | ||
| 103 | + final track = localMediaStream?.getVideoTracks(); | ||
| 104 | + if (track != null) { | ||
| 105 | + final imageCapture = ImageCapture(track.first); | ||
| 106 | + final photoCapabilities = await promiseToFuture<PhotoCapabilities>( | ||
| 107 | + imageCapture.getPhotoCapabilities(), | ||
| 108 | + ); | ||
| 109 | + final fillLightMode = photoCapabilities.fillLightMode; | ||
| 110 | + if (fillLightMode != null) { | ||
| 111 | + return fillLightMode; | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + } catch (e) { | ||
| 115 | + // ImageCapture is not supported by some browsers: | ||
| 116 | + // https://developer.mozilla.org/en-US/docs/Web/API/ImageCapture#browser_compatibility | ||
| 117 | + } | ||
| 118 | + return []; | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + @override | ||
| 122 | + Future<bool> hasTorch() async { | ||
| 123 | + return (await getSupportedTorchStates()).isNotEmpty; | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + @override | ||
| 127 | + Future<void> toggleTorch({required bool enabled}) async { | ||
| 128 | + final hasTorch = await this.hasTorch(); | ||
| 129 | + if (hasTorch) { | ||
| 130 | + final track = localMediaStream?.getVideoTracks(); | ||
| 131 | + await track?.first.applyConstraints({ | ||
| 132 | + 'advanced': [ | ||
| 133 | + {'torch': enabled} | ||
| 134 | + ] | ||
| 135 | + }); | ||
| 136 | + } | ||
| 137 | + } | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +@JS('Promise') | ||
| 141 | +@staticInterop | ||
| 142 | +class Promise<T> {} | ||
| 143 | + | ||
| 144 | +@JS() | ||
| 145 | +@anonymous | ||
| 146 | +class PhotoCapabilities { | ||
| 147 | + /// Returns an array of available fill light options. Options include auto, off, or flash. | ||
| 148 | + external List<String>? get fillLightMode; | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +@JS('ImageCapture') | ||
| 152 | +@staticInterop | ||
| 153 | +class ImageCapture { | ||
| 154 | + /// MediaStreamTrack | ||
| 155 | + external factory ImageCapture(dynamic track); | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +extension ImageCaptureExt on ImageCapture { | ||
| 159 | + external Promise<PhotoCapabilities> getPhotoCapabilities(); | ||
| 160 | +} |
| 1 | @JS() | 1 | @JS() |
| 2 | library jsqr; | 2 | library jsqr; |
| 3 | 3 | ||
| 4 | +import 'dart:async'; | ||
| 5 | +import 'dart:html'; | ||
| 4 | import 'dart:typed_data'; | 6 | import 'dart:typed_data'; |
| 5 | 7 | ||
| 6 | import 'package:js/js.dart'; | 8 | import 'package:js/js.dart'; |
| 9 | +import 'package:mobile_scanner/src/enums/camera_facing.dart'; | ||
| 10 | +import 'package:mobile_scanner/src/objects/barcode.dart'; | ||
| 11 | +import 'package:mobile_scanner/src/web/base.dart'; | ||
| 7 | 12 | ||
| 8 | @JS('jsQR') | 13 | @JS('jsQR') |
| 9 | external Code? jsQR(dynamic data, int? width, int? height); | 14 | external Code? jsQR(dynamic data, int? width, int? height); |
| @@ -14,3 +19,72 @@ class Code { | @@ -14,3 +19,72 @@ class Code { | ||
| 14 | 19 | ||
| 15 | external Uint8ClampedList get binaryData; | 20 | external Uint8ClampedList get binaryData; |
| 16 | } | 21 | } |
| 22 | + | ||
| 23 | +class JsQrCodeReader extends WebBarcodeReaderBase | ||
| 24 | + with InternalStreamCreation, InternalTorchDetection { | ||
| 25 | + JsQrCodeReader({required super.videoContainer}); | ||
| 26 | + | ||
| 27 | + @override | ||
| 28 | + bool get isStarted => localMediaStream != null; | ||
| 29 | + | ||
| 30 | + @override | ||
| 31 | + Future<void> start({ | ||
| 32 | + required CameraFacing cameraFacing, | ||
| 33 | + }) async { | ||
| 34 | + videoContainer.children = [video]; | ||
| 35 | + | ||
| 36 | + final stream = await initMediaStream(cameraFacing); | ||
| 37 | + | ||
| 38 | + prepareVideoElement(video); | ||
| 39 | + if (stream != null) { | ||
| 40 | + await attachStreamToVideo(stream, video); | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @override | ||
| 45 | + void prepareVideoElement(VideoElement videoSource) { | ||
| 46 | + // required to tell iOS safari we don't want fullscreen | ||
| 47 | + videoSource.setAttribute('playsinline', 'true'); | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + @override | ||
| 51 | + Future<void> attachStreamToVideo( | ||
| 52 | + MediaStream stream, | ||
| 53 | + VideoElement videoSource, | ||
| 54 | + ) async { | ||
| 55 | + localMediaStream = stream; | ||
| 56 | + videoSource.srcObject = stream; | ||
| 57 | + await videoSource.play(); | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + @override | ||
| 61 | + Stream<Barcode?> detectBarcodeContinuously() async* { | ||
| 62 | + yield* Stream.periodic(frameInterval, (_) { | ||
| 63 | + return _captureFrame(video); | ||
| 64 | + }).asyncMap((event) async { | ||
| 65 | + final code = await event; | ||
| 66 | + if (code == null) { | ||
| 67 | + return null; | ||
| 68 | + } | ||
| 69 | + return Barcode( | ||
| 70 | + rawValue: code.data, | ||
| 71 | + rawBytes: Uint8List.fromList(code.binaryData), | ||
| 72 | + format: BarcodeFormat.qrCode, | ||
| 73 | + ); | ||
| 74 | + }); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + /// Captures a frame and analyzes it for QR codes | ||
| 78 | + Future<Code?> _captureFrame(VideoElement video) async { | ||
| 79 | + if (localMediaStream == null) return null; | ||
| 80 | + final canvas = | ||
| 81 | + CanvasElement(width: video.videoWidth, height: video.videoHeight); | ||
| 82 | + final ctx = canvas.context2D; | ||
| 83 | + | ||
| 84 | + ctx.drawImage(video, 0, 0); | ||
| 85 | + final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!); | ||
| 86 | + | ||
| 87 | + final code = jsQR(imgData.data, canvas.width, canvas.height); | ||
| 88 | + return code; | ||
| 89 | + } | ||
| 90 | +} |
lib/src/web/qr_scanner.dart
deleted
100644 → 0
lib/src/web/zxing.dart
0 → 100644
| 1 | +import 'dart:async'; | ||
| 2 | +import 'dart:html'; | ||
| 3 | +import 'dart:typed_data'; | ||
| 4 | + | ||
| 5 | +import 'package:js/js.dart'; | ||
| 6 | +import 'package:mobile_scanner/src/enums/camera_facing.dart'; | ||
| 7 | +import 'package:mobile_scanner/src/objects/barcode.dart'; | ||
| 8 | +import 'package:mobile_scanner/src/web/base.dart'; | ||
| 9 | + | ||
| 10 | +@JS('ZXing.BrowserMultiFormatReader') | ||
| 11 | +@staticInterop | ||
| 12 | +class JsZXingBrowserMultiFormatReader { | ||
| 13 | + /// https://github.com/zxing-js/library/blob/1e9ccb3b6b28d75b9eef866dba196d8937eb4449/src/browser/BrowserMultiFormatReader.ts#L11 | ||
| 14 | + external factory JsZXingBrowserMultiFormatReader( | ||
| 15 | + dynamic hints, | ||
| 16 | + int timeBetweenScansMillis, | ||
| 17 | + ); | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +@JS() | ||
| 21 | +@anonymous | ||
| 22 | +abstract class Result { | ||
| 23 | + /// raw text encoded by the barcode | ||
| 24 | + external String get text; | ||
| 25 | + | ||
| 26 | + /// Returns raw bytes encoded by the barcode, if applicable, otherwise null | ||
| 27 | + external Uint8ClampedList? get rawBytes; | ||
| 28 | + | ||
| 29 | + /// Representing the format of the barcode that was decoded | ||
| 30 | + external int? format; | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +extension ResultExt on Result { | ||
| 34 | + Barcode toBarcode() { | ||
| 35 | + final rawBytes = this.rawBytes; | ||
| 36 | + return Barcode( | ||
| 37 | + rawValue: text, | ||
| 38 | + rawBytes: rawBytes != null ? Uint8List.fromList(rawBytes) : null, | ||
| 39 | + format: barcodeFormat, | ||
| 40 | + ); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /// https://github.com/zxing-js/library/blob/1e9ccb3b6b28d75b9eef866dba196d8937eb4449/src/core/BarcodeFormat.ts#L28 | ||
| 44 | + BarcodeFormat get barcodeFormat { | ||
| 45 | + switch (format) { | ||
| 46 | + case 1: | ||
| 47 | + return BarcodeFormat.aztec; | ||
| 48 | + case 2: | ||
| 49 | + return BarcodeFormat.codebar; | ||
| 50 | + case 3: | ||
| 51 | + return BarcodeFormat.code39; | ||
| 52 | + case 4: | ||
| 53 | + return BarcodeFormat.code128; | ||
| 54 | + case 5: | ||
| 55 | + return BarcodeFormat.dataMatrix; | ||
| 56 | + case 6: | ||
| 57 | + return BarcodeFormat.ean8; | ||
| 58 | + case 7: | ||
| 59 | + return BarcodeFormat.ean13; | ||
| 60 | + case 8: | ||
| 61 | + return BarcodeFormat.itf; | ||
| 62 | + // case 9: | ||
| 63 | + // return BarcodeFormat.maxicode; | ||
| 64 | + case 10: | ||
| 65 | + return BarcodeFormat.pdf417; | ||
| 66 | + case 11: | ||
| 67 | + return BarcodeFormat.qrCode; | ||
| 68 | + // case 12: | ||
| 69 | + // return BarcodeFormat.rss14; | ||
| 70 | + // case 13: | ||
| 71 | + // return BarcodeFormat.rssExp; | ||
| 72 | + case 14: | ||
| 73 | + return BarcodeFormat.upcA; | ||
| 74 | + case 15: | ||
| 75 | + return BarcodeFormat.upcE; | ||
| 76 | + default: | ||
| 77 | + return BarcodeFormat.unknown; | ||
| 78 | + } | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +typedef BarcodeDetectionCallback = void Function( | ||
| 83 | + Result? result, | ||
| 84 | + dynamic error, | ||
| 85 | +); | ||
| 86 | + | ||
| 87 | +extension JsZXingBrowserMultiFormatReaderExt | ||
| 88 | + on JsZXingBrowserMultiFormatReader { | ||
| 89 | + external Promise<void> decodeFromVideoElementContinuously( | ||
| 90 | + VideoElement source, | ||
| 91 | + BarcodeDetectionCallback callbackFn, | ||
| 92 | + ); | ||
| 93 | + | ||
| 94 | + /// Continuously decodes from video input | ||
| 95 | + external void decodeContinuously( | ||
| 96 | + VideoElement element, | ||
| 97 | + BarcodeDetectionCallback callbackFn, | ||
| 98 | + ); | ||
| 99 | + | ||
| 100 | + external Promise<void> decodeFromStream( | ||
| 101 | + MediaStream stream, | ||
| 102 | + VideoElement videoSource, | ||
| 103 | + BarcodeDetectionCallback callbackFn, | ||
| 104 | + ); | ||
| 105 | + | ||
| 106 | + external Promise<void> decodeFromConstraints( | ||
| 107 | + dynamic constraints, | ||
| 108 | + VideoElement videoSource, | ||
| 109 | + BarcodeDetectionCallback callbackFn, | ||
| 110 | + ); | ||
| 111 | + | ||
| 112 | + external void stopContinuousDecode(); | ||
| 113 | + | ||
| 114 | + external VideoElement prepareVideoElement(VideoElement videoSource); | ||
| 115 | + | ||
| 116 | + /// Defines what the [videoElement] src will be. | ||
| 117 | + external void addVideoSource( | ||
| 118 | + VideoElement videoElement, | ||
| 119 | + MediaStream stream, | ||
| 120 | + ); | ||
| 121 | + | ||
| 122 | + external bool isVideoPlaying(VideoElement video); | ||
| 123 | + | ||
| 124 | + external void reset(); | ||
| 125 | + | ||
| 126 | + /// The HTML video element, used to display the camera stream. | ||
| 127 | + external VideoElement? videoElement; | ||
| 128 | + | ||
| 129 | + /// The stream output from camera. | ||
| 130 | + external MediaStream? stream; | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +class ZXingBarcodeReader extends WebBarcodeReaderBase | ||
| 134 | + with InternalStreamCreation, InternalTorchDetection { | ||
| 135 | + late final JsZXingBrowserMultiFormatReader _reader = | ||
| 136 | + JsZXingBrowserMultiFormatReader( | ||
| 137 | + null, | ||
| 138 | + frameInterval.inMilliseconds, | ||
| 139 | + ); | ||
| 140 | + | ||
| 141 | + ZXingBarcodeReader({required super.videoContainer}); | ||
| 142 | + | ||
| 143 | + @override | ||
| 144 | + bool get isStarted => localMediaStream != null; | ||
| 145 | + | ||
| 146 | + @override | ||
| 147 | + Future<void> start({ | ||
| 148 | + required CameraFacing cameraFacing, | ||
| 149 | + }) async { | ||
| 150 | + videoContainer.children = [video]; | ||
| 151 | + | ||
| 152 | + final stream = await initMediaStream(cameraFacing); | ||
| 153 | + | ||
| 154 | + prepareVideoElement(video); | ||
| 155 | + if (stream != null) { | ||
| 156 | + await attachStreamToVideo(stream, video); | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + @override | ||
| 161 | + void prepareVideoElement(VideoElement videoSource) { | ||
| 162 | + _reader.prepareVideoElement(videoSource); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + @override | ||
| 166 | + Future<void> attachStreamToVideo( | ||
| 167 | + MediaStream stream, | ||
| 168 | + VideoElement videoSource, | ||
| 169 | + ) async { | ||
| 170 | + _reader.addVideoSource(videoSource, stream); | ||
| 171 | + _reader.videoElement = videoSource; | ||
| 172 | + _reader.stream = stream; | ||
| 173 | + localMediaStream = stream; | ||
| 174 | + await videoSource.play(); | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + @override | ||
| 178 | + Stream<Barcode?> detectBarcodeContinuously() { | ||
| 179 | + final controller = StreamController<Barcode?>(); | ||
| 180 | + controller.onListen = () async { | ||
| 181 | + _reader.decodeContinuously( | ||
| 182 | + video, | ||
| 183 | + allowInterop((result, error) { | ||
| 184 | + if (result != null) { | ||
| 185 | + controller.add(result.toBarcode()); | ||
| 186 | + } | ||
| 187 | + }), | ||
| 188 | + ); | ||
| 189 | + }; | ||
| 190 | + controller.onCancel = () { | ||
| 191 | + _reader.stopContinuousDecode(); | ||
| 192 | + }; | ||
| 193 | + return controller.stream; | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + @override | ||
| 197 | + Future<void> stop() async { | ||
| 198 | + _reader.reset(); | ||
| 199 | + super.stop(); | ||
| 200 | + } | ||
| 201 | +} |
-
Please register or login to post a comment