Julian Steenbakker

feat: update web integration

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:mobile_scanner/src/web/jsqr.dart';
import 'dart:html' as html;
import 'dart:ui' as ui;
import 'package:mobile_scanner/src/web/media.dart';
/// This plugin is the web implementation of mobile_scanner.
/// It only supports QR codes.
class MobileScannerWebPlugin {
static void registerWith(Registrar registrar) {
PluginEventChannel event = PluginEventChannel(
'dev.steenbakker.mobile_scanner/scanner/event',
const StandardMethodCodec(),
registrar);
MethodChannel channel = MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
const StandardMethodCodec(),
registrar);
final MobileScannerWebPlugin instance = MobileScannerWebPlugin();
WidgetsFlutterBinding.ensureInitialized();
channel.setMethodCallHandler(instance.handleMethodCall);
event.setController(instance.controller);
}
// Controller to send events back to the framework
StreamController controller = StreamController();
// The video stream. Will be initialized later to see which camera needs to be used.
html.MediaStream? _localStream;
html.VideoElement video = html.VideoElement();
// ID of the video feed
String viewID = 'WebScanner-${DateTime.now().millisecondsSinceEpoch}';
// Determine wether device has flas
bool hasFlash = false;
// Timer used to capture frames to be analyzed
Timer? _frameInterval;
html.DivElement vidDiv = html.DivElement();
/// Handle incomming messages
Future<dynamic> handleMethodCall(MethodCall call) async {
switch (call.method) {
case 'start':
return await _start(call.arguments);
case 'torch':
return await _torch(call.arguments);
case 'stop':
return await cancel();
default:
throw PlatformException(
code: 'Unimplemented',
details: "The mobile_scanner plugin for web doesn't implement "
"the method '${call.method}'");
}
}
/// Can enable or disable the flash if available
Future<void> _torch(arguments) async {
if (hasFlash) {
final track = _localStream?.getVideoTracks();
await track!.first.applyConstraints({
'advanced': {'torch': arguments == 1}
});
} else {
controller.addError('Device has no flash');
}
}
/// Starts the video stream and the scanner
Future<Map> _start(arguments) async {
vidDiv.children = [video];
final CameraFacing cameraFacing =
arguments['cameraFacing'] ?? CameraFacing.front;
// See https://github.com/flutter/flutter/issues/41563
// ignore: UNDEFINED_PREFIXED_NAME
ui.platformViewRegistry.registerViewFactory(
viewID,
(int id) => vidDiv
..style.width = '100%'
..style.height = '100%');
// Check if stream is running
if (_localStream != null) {
return {
'ViewID': viewID,
'videoWidth': video.videoWidth,
'videoHeight': video.videoHeight
};
}
try {
// Check if browser supports multiple camera's and set if supported
Map? capabilities =
html.window.navigator.mediaDevices?.getSupportedConstraints();
if (capabilities != null && capabilities['facingMode']) {
UserMediaOptions constraints = UserMediaOptions(
video: VideoOptions(
facingMode:
(cameraFacing == CameraFacing.front ? 'user' : 'environment'),
width: {'ideal': 4096},
height: {'ideal': 2160},
));
_localStream =
await html.window.navigator.getUserMedia(video: constraints);
} else {
_localStream = await html.window.navigator.getUserMedia(video: true);
}
video.srcObject = _localStream;
// TODO: fix flash light. See https://github.com/dart-lang/sdk/issues/48533
// final track = _localStream?.getVideoTracks();
// if (track != null) {
// final imageCapture = html.ImageCapture(track.first);
// final photoCapabilities = await imageCapture.getPhotoCapabilities();
// }
// required to tell iOS safari we don't want fullscreen
video.setAttribute('playsinline', 'true');
await video.play();
// Then capture a frame to be analyzed every 200 miliseconds
_frameInterval =
Timer.periodic(const Duration(milliseconds: 200), (timer) {
_captureFrame();
});
return {
'ViewID': viewID,
'videoWidth': video.videoWidth,
'videoHeight': video.videoHeight,
'torchable': hasFlash
};
} catch (e) {
throw PlatformException(code: 'MobileScannerWeb', message: e.toString());
}
}
/// Check if any camera's are available
static Future<bool> cameraAvailable() async {
final sources =
await html.window.navigator.mediaDevices!.enumerateDevices();
for (final e in sources) {
if (e.kind == 'videoinput') {
return true;
}
}
return false;
}
/// Stops the video feed and analyzer
Future<void> cancel() async {
try {
// Stop the camera stream
_localStream!.getTracks().forEach((track) {
if (track.readyState == 'live') {
track.stop();
}
});
} catch (e) {
debugPrint('Failed to stop stream: $e');
}
video.srcObject = null;
_localStream = null;
_frameInterval?.cancel();
_frameInterval = null;
}
/// Captures a frame and analyzes it for QR codes
Future<dynamic> _captureFrame() async {
if (_localStream == null) return null;
final canvas =
html.CanvasElement(width: video.videoWidth, height: video.videoHeight);
final ctx = canvas.context2D;
ctx.drawImage(video, 0, 0);
final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);
final code = jsQR(imgData.data, canvas.width, canvas.height);
if (code != null) {
controller.add({'name': 'barcodeWeb', 'data': code.data});
}
}
}
... ...
... ... @@ -2,10 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'mobile_scanner_arguments.dart';
import 'web/flutter_qr_web.dart';
enum Ratio { ratio_4_3, ratio_16_9 }
/// A widget showing a live camera preview.
... ... @@ -64,12 +60,6 @@ class _MobileScannerState extends State<MobileScanner>
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return WebScanner(
onDetect: (barcode) => widget.onDetect!(barcode, null),
cameraFacing: CameraFacing.back,
);
} else {
return LayoutBuilder(builder: (context, BoxConstraints constraints) {
return ValueListenableBuilder(
valueListenable: controller.args,
... ... @@ -96,7 +86,7 @@ class _MobileScannerState extends State<MobileScanner>
child: SizedBox(
width: value.size.width,
height: value.size.height,
child: Texture(textureId: value.textureId),
child: kIsWeb ? HtmlElementView(viewType: value.webId!) : Texture(textureId: value.textureId!),
),
),
),
... ... @@ -105,7 +95,6 @@ class _MobileScannerState extends State<MobileScanner>
});
});
}
}
@override
void didUpdateWidget(covariant MobileScanner oldWidget) {
... ...
... ... @@ -3,14 +3,16 @@ import 'package:flutter/material.dart';
/// Camera args for [CameraView].
class MobileScannerArguments {
/// The texture id.
final int textureId;
final int? textureId;
/// Size of the texture.
final Size size;
final bool hasTorch;
final String? webId;
/// Create a [MobileScannerArguments].
MobileScannerArguments(
{required this.textureId, required this.size, required this.hasTorch});
{this.textureId, required this.size, required this.hasTorch, this.webId});
}
... ...
... ... @@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
... ... @@ -98,6 +99,9 @@ class MobileScannerController {
case 'barcodeMac':
barcodesController.add(Barcode(rawValue: data['payload']));
break;
case 'barcodeWeb':
barcodesController.add(Barcode(rawValue: data));
break;
default:
throw UnimplementedError();
}
... ... @@ -125,13 +129,15 @@ class MobileScannerController {
// setAnalyzeMode(AnalyzeMode.barcode.index);
// Check authorization status
if (!kIsWeb) {
MobileScannerState state =
MobileScannerState.values[await methodChannel.invokeMethod('state')];
switch (state) {
case MobileScannerState.undetermined:
final bool result = await methodChannel.invokeMethod('request');
state =
result ? MobileScannerState.authorized : MobileScannerState.denied;
state = result
? MobileScannerState.authorized
: MobileScannerState.denied;
break;
case MobileScannerState.denied:
isStarting = false;
... ... @@ -139,6 +145,7 @@ class MobileScannerController {
case MobileScannerState.authorized:
break;
}
}
cameraFacingState.value = facing;
... ... @@ -174,10 +181,20 @@ class MobileScannerController {
}
hasTorch = startResult['torchable'];
if (kIsWeb) {
args.value = MobileScannerArguments(
webId: startResult['ViewID'],
size: Size(startResult['videoWidth'], startResult['videoHeight']),
hasTorch: hasTorch);
} else {
args.value = MobileScannerArguments(
textureId: startResult['textureId'],
size: toSize(startResult['size']),
hasTorch: hasTorch);
}
isStarting = false;
}
... ...
// ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:async';
import 'dart:core';
import 'dart:html' as html;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../mobile_scanner.dart';
import 'jsqr.dart';
import 'media.dart';
/// Even though it has been highly modified, the origial implementation has been
/// adopted from https://github.com:treeder/jsqr_flutter
///
/// Copyright 2020 @treeder
/// Copyright 2021 The one with the braid
class WebScanner extends StatefulWidget {
final Function(Barcode) onDetect;
final CameraFacing? cameraFacing;
const WebScanner(
{Key? key,
required this.onDetect,
this.cameraFacing = CameraFacing.front})
: super(key: key);
@override
_WebScannerState createState() => _WebScannerState();
// need a global for the registerViewFactory
static html.DivElement vidDiv = html.DivElement();
static Future<bool> cameraAvailable() async {
final sources =
await html.window.navigator.mediaDevices!.enumerateDevices();
// List<String> vidIds = [];
var hasCam = false;
for (final e in sources) {
if (e.kind == 'videoinput') {
// vidIds.add(e['deviceId']);
hasCam = true;
}
}
return hasCam;
}
}
class _WebScannerState extends State<WebScanner> {
// Which way the camera is facing
// late CameraFacing facing;
// The camera stream to display to the user
html.MediaStream? _localStream;
// Check if analyzer is processing barcode
bool _currentlyProcessing = false;
// QRViewControllerWeb? _controller;
// Set size of the webview
// Size _size = const Size(0, 0);
// TODO: Timer for capture?
Timer? timer;
// String? code;
// TODO: Error message if error
String? _errorMsg;
// Video element to be played on
html.VideoElement video = html.VideoElement();
// ID of the video feed
String viewID =
'WebScanner-' + DateTime.now().millisecondsSinceEpoch.toString();
// final StreamController<Barcode> _scanUpdateController =
// StreamController<Barcode>();
// Timer for interval capture
Timer? _frameIntervall;
@override
void initState() {
super.initState();
// facing = widget.cameraFacing ?? CameraFacing.front;
WebScanner.vidDiv.children = [video];
// ignore: UNDEFINED_PREFIXED_NAME
ui.platformViewRegistry
.registerViewFactory(viewID, (int id) => WebScanner.vidDiv);
// giving JavaScipt some time to process the DOM changes
Timer(const Duration(milliseconds: 500), () {
start();
});
}
/// Initialize camera and capture frame
Future start() async {
await _startVideoStream();
_frameIntervall?.cancel();
_frameIntervall =
Timer.periodic(const Duration(milliseconds: 200), (timer) {
_captureFrame();
});
}
void cancel() {
if (timer != null) {
timer!.cancel();
timer = null;
}
if (_currentlyProcessing) {
_stopVideoStream();
}
}
@override
void dispose() {
cancel();
super.dispose();
}
/// Starts a video stream if not started already
Future<void> _startVideoStream() async {
// Check if stream is running
if (_localStream != null) return;
try {
// Check if browser supports multiple camera's and set if supported
Map? capabilities =
html.window.navigator.mediaDevices?.getSupportedConstraints();
if (capabilities != null && capabilities['facingMode']) {
UserMediaOptions constraints = UserMediaOptions(
video: VideoOptions(
facingMode: (widget.cameraFacing == CameraFacing.front
? 'user'
: 'environment'),
width: {'ideal': 4096},
height: {'ideal': 2160},
));
_localStream =
await html.window.navigator.getUserMedia(video: constraints);
} else {
_localStream = await html.window.navigator.getUserMedia(video: true);
}
video.srcObject = _localStream;
// required to tell iOS safari we don't want fullscreen
video.setAttribute('playsinline', 'true');
// TODO: Check controller
// if (_controller == null) {
// _controller = QRViewControllerWeb(this);
// widget.onPlatformViewCreated(_controller!);
// }
await video.play();
} catch (e) {
cancel();
setState(() {
_errorMsg = e.toString();
});
return;
}
if (!mounted) return;
setState(() {
_currentlyProcessing = true;
});
}
Future<void> _stopVideoStream() async {
try {
// Stop the camera stream
_localStream!.getTracks().forEach((track) {
if (track.readyState == 'live') {
track.stop();
}
});
video.srcObject = null;
_localStream = null;
} catch (e) {
debugPrint('Failed to stop stream: $e');
}
}
Future<dynamic> _captureFrame() async {
if (_localStream == null) return null;
final canvas = html.CanvasElement(width: video.videoWidth, height: video.videoHeight);
final ctx = canvas.context2D;
ctx.drawImage(video, 0, 0);
final imgData = ctx.getImageData(0, 0, canvas.width!, canvas.height!);
// final size =
// Size(canvas.width?.toDouble() ?? 0, canvas.height?.toDouble() ?? 0);
// if (size != _size) {
// setState(() {
// _setCanvasSize(size);
// });
// }
// debugPrint('img.data: ${imgData.data}');
final code = jsQR(imgData.data, canvas.width, canvas.height);
// ignore: unnecessary_null_comparison
if (code != null) {
debugPrint('CODE: $code');
// widget.onDetect(Barcode(rawValue: code.data));
// print('Barcode: ${code.data}');
// _scanUpdateController
// .add(Barcode(rawValue: code.data));
}
}
@override
Widget build(BuildContext context) {
if (_errorMsg != null) {
return Center(child: Text(_errorMsg!));
}
if (_localStream == null) {
return const Center(child: CircularProgressIndicator());
}
return SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: FittedBox(
child: SizedBox(
width: video.videoWidth.toDouble(),
height: video.videoHeight.toDouble(),
child: HtmlElementView(viewType: viewID))));
}
}
... ... @@ -4,7 +4,7 @@ library jsqr;
import 'package:js/js.dart';
@JS('jsQR')
external Code jsQR(var data, int? width, int? height);
external Code? jsQR(var data, int? width, int? height);
@JS()
class Code {
... ...
... ... @@ -11,6 +11,8 @@ dependencies:
js: ^0.6.4
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
dev_dependencies:
flutter_test:
... ... @@ -27,3 +29,6 @@ flutter:
pluginClass: MobileScannerPlugin
macos:
pluginClass: MobileScannerPlugin
web:
pluginClass: MobileScannerWebPlugin
fileName: mobile_scanner_web_plugin.dart
\ No newline at end of file
... ...