import BarcodeDetectorPolyfill from './BarcodeDetectorPolyfill.js';
import { camelJoin, snakeToCamel } from './MultiScannerElement.utils.js';
import {
MEDIA_TRACK_CONSTRAINTS,
IMAGE_TRACK_CONSTRAINTS,
VIDEO_TRACK_CONSTRAINTS,
BARCODE_SCANNER_ATTRIBUTES,
SUPPORTED_FORMATS_MAP,
HEX_COLOR_REGEX,
STROKE_LINE_JOINS,
} from './MultiScannerElement.constants.js';
// Establish BarcodeDetector ( Polyfill )
BarcodeDetectorPolyfill.setupPolyfill();
/*
window['BarcodeDetector'] = BarcodeDetectorPolyfill;
if (!('BarcodeDetector' in window)) {
return false;
}
return true;
}
await BarcodeDetector.getSupportedFormats()
*/
// prettier-ignore
const default_scanner_html = `
<!--
Multi-Scanner Style
-->
<style>
#multi-scanner-container {
display: grid;
grid-template-areas:
"main";
place-items: center;
}
#multi-scanner-container > * {
grid-area: main;
}
#multi-scanner-video-webcam {
border: 1px solid red;
}
#multi-scanner-canvas-output {
border: 1px solid lime;
}
</style>
<!--
Multi-Scanner Content
-->
<span id="multi-scanner-container">
<video id="multi-scanner-video-webcam" playsinline="true"></video>
<canvas id="multi-scanner-canvas-output"></canvas>
<div id="multi-scanner-loading-msg">🎥 Unable to access video stream</div>
</span>
`
/** Barcode scanning web component capable of scanning multiple formats of barcodes. */
class MultiScannerElement extends HTMLElement {
static get observedAttributes() {
/**
* Possible Observed Attributes for the future.
* navigator.mediaDevices.getSupportedConstraints().map( c => camelJoin(c, '-'))
*/
return [
...MEDIA_TRACK_CONSTRAINTS,
...Object.keys(IMAGE_TRACK_CONSTRAINTS).map((c) => camelJoin(c, '-')),
...Object.keys(VIDEO_TRACK_CONSTRAINTS).map((c) => camelJoin(c, '-')),
...BARCODE_SCANNER_ATTRIBUTES,
];
}
/**
* Create a MultiScannerElement.
*/
constructor() {
// Call parent constructor.
super();
// Template the Shadow DOM.
this.attachShadow({ mode: 'open' });
this.template();
// States - Changed when interacting with camera and displaying state.
this._states = ['start', 'pause', 'stop'];
// State.
this._state = '';
// Formats.
this._formats = Object.values(SUPPORTED_FORMATS_MAP);
// BarcodeDetector
// this.barcodeDetector = new BarcodeDetector({ formats: this.formats });
this.barcodeDetector = new BarcodeDetectorPolyfill({
formats: this.formats,
});
// Set Camera Access Message.
this.camera_access_message =
String() +
'Your Devices Camera information is needed in order ' +
'for this Scanner to function properly. Are you ' +
'ready to allow access to your Devices Camera?';
// Initialize Default settings & constraints.
this._settings = {};
// Stream.
this._stream = null;
// Track.
this._track = null;
// Constraints
this._constraint_delay = 700;
// TODO: What we would like the arguments to be.
this._media_track_constraints = {
video: { facingMode: 'environment' },
};
// CameraId.
this._cameraId = this.guid();
this.setAttribute('cameraId', this.cameraId);
// Cameras.
// - Create shared cameras object.
// - Add self to cameras.
if (!this.__proto__.cameras) {
this.__proto__.cameras = {};
}
this.__proto__.cameras[this.cameraId] = this;
// Canvas Contexts.
this.canvas_output = this._$Map.$canvas_output.getContext('2d');
// Line Args
this._default_line_color = '#ff0000';
this._default_line_width = 4;
this._default_line_join = 'round';
this._default_line_shadow_color = '#ff0000';
this._default_line_shadow_blur = 4;
// IMAGE_TRACK_CONSTRAINTS Properties
Object.entries(IMAGE_TRACK_CONSTRAINTS).map(([c_prop, c_type]) => {
const c_attr = camelJoin(c_prop);
Object.defineProperty(this, c_prop, {
get: () => {
return this[`_${c_prop}`];
},
set: (new_c) => {
this._constraintPropUpdate({ c_prop, c_attr, new_c, c_type });
},
});
});
// VIDEO_TRACK_CONSTRAINTS Properties
Object.entries(VIDEO_TRACK_CONSTRAINTS).map(([c_prop, c_type]) => {
const c_attr = camelJoin(c_prop);
Object.defineProperty(this, c_prop, {
get: () => {
return this[`_${c_prop}`];
},
set: (new_c) => {
this._constraintPropUpdate({ c_prop, c_attr, new_c, c_type });
},
});
});
}
/**
* Attributes on this element have changed.
*
* @param name {string} - Which attribute has chaned.
* @param oldValue {string} - Previous value of attribute.
* @param newValue {string} - Current value of attribute.
*/
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name} changed from ${oldValue} to ${newValue}`);
const c_prop = snakeToCamel(name);
const c_attr = name;
/* --- MEDIA_TRACK_CONSTRAINTS - BEGIN ---------------------------------- */
if (name === 'device') {
// Check if new device is in videoDevicesPromise.
if (this._videoDevices) {
let newDevice = this._videoDevices.find((device) => {
if (device.deviceId === newValue) {
return true;
}
if (device.label === newValue) {
return true;
}
return false;
});
if (newDevice) {
this._device = newValue;
if (this.state === 'started') {
this.restart();
}
}
} else {
// Default to updating the device if _videoDevices has not been set yet.
this._device = newValue;
}
}
/* --- MEDIA_TRACK_CONSTRAINTS - END ------------------------------------ */
/* --- IMAGE_TRACK_CONSTRAINTS - BEGIN ---------------------------------- */
if (c_prop in IMAGE_TRACK_CONSTRAINTS) {
const c_type = IMAGE_TRACK_CONSTRAINTS[c_prop];
const new_c = newValue;
this._constraintAttrUpdate({ c_prop, c_attr, new_c, c_type });
}
/* --- IMAGE_TRACK_CONSTRAINTS - END ------------------------------------ */
/* --- VIDEO_TRACK_CONSTRAINTS - BEGIN ---------------------------------- */
if (c_prop in VIDEO_TRACK_CONSTRAINTS) {
const c_type = VIDEO_TRACK_CONSTRAINTS[c_prop];
const new_c = newValue;
this._constraintAttrUpdate({ c_prop, c_attr, new_c, c_type });
}
/* --- VIDEO_TRACK_CONSTRAINTS - END ------------------------------------ */
/* --- BARCODE_SCANNER_ATTRIBUTES - BEGIN ------------------------------- */
if (name === 'state') {
if (this._states.includes(newValue)) {
this._state = newValue;
// Process state change.
if (newValue === 'start') {
this.start();
} else if (newValue === 'pause') {
this.pause();
} else if (newValue === 'stop') {
this.stop();
}
}
}
if (name === 'formats') {
let newFormats = newValue.replaceAll(' ', '').split(',');
let newValidFormats = newFormats.filter((f) => {
return Object.values(SUPPORTED_FORMATS_MAP).includes(f);
});
this._formats = newValidFormats;
this.barcodeDetector = new BarcodeDetector({ formats: this.formats });
}
if (name === 'web-worker') {
if (this.hasAttribute(name)) {
this._web_worker = true;
} else {
this._web_worker = false;
}
}
if (name === 'line-color') {
if (HEX_COLOR_REGEX.test(newValue)) {
this._line_color = newValue;
}
}
if (name === 'line-width') {
if (!isNaN(parseInt(newValue)) && parseInt(newValue) > -1) {
this._line_width = parseFloat(newValue);
}
}
if (name === 'line-shadow-blur') {
if (!isNaN(parseInt(newValue)) && parseInt(newValue) > -1) {
this._line_shadow_blur = parseFloat(newValue);
}
}
if (name === 'line-shadow-color') {
if (HEX_COLOR_REGEX.test(newValue)) {
this._line_shadow_color = newValue;
}
}
if (name === 'line-join') {
if (STROKE_LINE_JOINS.includes(newValue)) {
this._line_join = newValue;
}
}
/* --- BARCODE_SCANNER_ATTRIBUTES - END --------------------------------- */
}
/**
* The element has been connected to the DOM.
*/
connectedCallback() {}
/**
* The element has been disconnected from the DOM.
*/
disconnectedCallback() {}
/**
* Constraint has been updated through the attribute. Sets the private
* private propterty.
*
* Respects supported constraints & track capabilities
*
* If a new constraint value is not a part of the supported constraints than
* the method will return.
*
* If the current track_capabilities is set then the new constraint value
* will be checked to ensure that it valid.
*
* @param {Object} obj - An Object
* @param {String} obj.c_prop - Property (camelCase) to be updated.
* @param {String} obj.c_attr - Attribute (snakeCase w/ hyphen) that was updated.
* @param {String} obj.new_c - New value of the constraint.
* @param {String} obj.c_type - Type of the constraint.
*/
_constraintAttrUpdate({ c_prop, c_attr, new_c, c_type }) {
let valid_image_constraints;
let valid_video_constraints;
let constraints_constant;
let current_constraints = { [c_prop]: new_c };
// Check IMAGE_TRACK_CONSTRAINTS.
constraints_constant = IMAGE_TRACK_CONSTRAINTS;
valid_image_constraints = this._reduce_constraints({
current_constraints,
constraints_constant,
});
// Check VIDEO_TRACK_CONSTRAINTS.
constraints_constant = VIDEO_TRACK_CONSTRAINTS;
valid_video_constraints = this._reduce_constraints({
current_constraints,
constraints_constant,
});
// Short circuit if constraint is invalid.
if (
!(
c_prop in
{
...valid_image_constraints,
...valid_video_constraints,
}
) &&
c_type !== 'Boolean'
) {
return;
}
if (c_type === 'String') {
this[`_${c_prop}`] = new_c;
}
if (c_type === 'ConstrainDOMString') {
this[`_${c_prop}`] = new_c;
}
if (c_type === 'Object') {
this[`_${c_prop}`] = JSON.parse(new_c);
}
// Handles min, max & step
if (c_type === 'ConstrainDouble') {
this[`_${c_prop}`] = parseFloat(new_c);
}
// Handles min, max & step
if (c_type === 'ConstrainULong') {
this[`_${c_prop}`] = parseInt(new_c);
}
if (c_type === 'Boolean') {
if (this.hasAttribute(c_attr)) {
this[`_${c_prop}`] = true;
} else {
this[`_${c_prop}`] = false;
}
}
// Apply updated image constraints.
if (Object.keys(valid_image_constraints).length > 0) {
this.applyConstraints();
}
// Restart Scanner to apply video constraints.
if (Object.keys(valid_video_constraints).length > 0) {
if (this.state === 'started') {
this.restart();
}
}
}
/**
* Constraint is being updated through the property. Sets the attribute if
* validation passes
*
* Respects supported constraints & track capabilities
*
* If a new constraint value is not a part of the supported constraints than
* the method will return.
*
* If the current track_capabilities is set then the new constraint value
* will be checked to ensure that it valid.
*
*
* @param {Object} obj - An Object
* @param {String} obj.c_prop - Property (camelCase) to be updated.
* @param {String} obj.c_attr - Attribute (snakeCase w/ hyphen) that was updated.
* @param {String} obj.new_c - New value of the constraint.
* @param {String} obj.c_type - Type of the constraint.
*/
_constraintPropUpdate({ c_prop, c_attr, new_c, c_type }) {
let valid_image_constraints;
let valid_video_constraints;
let constraints_constant;
let current_constraints = { [c_prop]: new_c };
// Check IMAGE_TRACK_CONSTRAINTS.
constraints_constant = IMAGE_TRACK_CONSTRAINTS;
valid_image_constraints = this._reduce_constraints({
current_constraints,
constraints_constant,
});
// Check VIDEO_TRACK_CONSTRAINTS.
constraints_constant = VIDEO_TRACK_CONSTRAINTS;
valid_video_constraints = this._reduce_constraints({
current_constraints,
constraints_constant,
});
// Short circuit if constraint is invalid.
if (
!(
c_prop in
{
...valid_image_constraints,
...valid_video_constraints,
}
) &&
c_type !== 'Boolean'
) {
return;
}
if (c_type === 'String') {
this.setAttribute(c_attr, new_c);
}
if (c_type === 'ConstrainDOMString') {
this.setAttribute(c_attr, new_c);
}
if (c_type === 'Object') {
this.setAttribute(c_attr, JSON.stringify(new_c));
}
if (c_type === 'ConstrainDouble') {
this.setAttribute(c_attr, parseFloat(new_c));
}
if (c_type === 'ConstrainULong') {
this.setAttribute(c_attr, parseInt(new_c));
}
if (c_type === 'Boolean') {
if (new_c) {
this.setAttribute(c_attr, '');
} else {
this.removeAttribute(c_attr);
}
}
// Apply updated image constraints.
if (Object.keys(valid_image_constraints).length > 0) {
this.applyConstraints();
}
// Restart Scanner to apply video constraints.
if (Object.keys(valid_video_constraints).length > 0) {
if (this.state === 'started') {
this.restart();
}
}
}
/**
* The default template HTML string.
*/
get _template() {
return default_scanner_html;
}
/**
* Sets shadow DOM to template HTML string.
*/
template() {
this.shadowRoot.innerHTML = this._template;
}
/**
* CameraId for the scanner.
*/
get cameraId() {
return this._cameraId;
}
/**
* Getting _stream.
*/
get stream() {
return this._stream;
}
/**
* Setting new _stream.
*/
set stream(newStream) {
let stream_type = Object.prototype.toString.call(newStream);
if (stream_type === '[object MediaStream]') {
this._stream = newStream;
this._track = this._stream.getTracks()[0];
}
}
/**
* Getting _track.
*/
get track() {
return this._track;
}
/**
* Setting new _track.
*/
set track(newTrack) {
let stream_type = Object.prototype.toString.call(newTrack);
if (stream_type === '[object MediaStreamTrack]') {
this._track = newTrack;
// Get Constraints
this._track_constraints = this._track.getConstraints();
}
}
/**
* Getting track_capabilities.
*/
get track_capabilities() {
// Get Capabilities
if (this._track !== null && this._track.getCapabilities) {
return this._track.getCapabilities();
} else {
return {};
}
}
/**
* Getting track_constraints.
*/
get track_constraints() {
// Get Capabilities
if (this._track && this._track.getConstraints) {
return this._track.getConstraints();
} else {
return {};
}
}
/**
* Getting track_settings.
*/
get track_settings() {
// Get Capabilities
if (this._track && this._track.getSettings) {
return this._track.getSettings();
} else {
return {};
}
}
/**
* Supported Track Constraints for this browser.
*/
get _supported_constraints() {
return navigator.mediaDevices.getSupportedConstraints();
}
/**
* Settings for the scanner.
*/
get settings() {
return this._settings;
}
/**
* Get state for the scanner.
*/
get state() {
return this._state;
}
/**
* Set state for the scanner.
*/
set state(newState) {
let newStateParsed = newState;
if (newState.endsWith('ed')) {
newStateParsed = newState.slice(0, newState.length - 2);
}
if (this._states.includes(newStateParsed)) {
this.setAttribute('state', newState);
}
}
/**
* Get formats for the scanner. The scannable types of barcodes.
*/
get formats() {
return this._formats;
}
/**
* Set formats for the scanner. The scannable types of barcodes.
*/
set formats(newFormats) {
// Check if pass a string. Convert to an Array.
if (Object.prototype.toString.call(newFormats) === '[object String]') {
newFormats = newFormats.replaceAll(' ', '').split(',');
}
let newValidFormats = newFormats.filter((f) => {
return Object.values(SUPPORTED_FORMATS_MAP).includes(f);
});
this._formats = newValidFormats;
this.barcodeDetector = new BarcodeDetector({ formats: this.formats });
this.setAttribute('formats', newValidFormats.join(', '));
}
/**
* Get device for the scanner.
*/
get device() {
let current_device;
if (this.track_settings && this.track_settings.deviceId) {
current_device = this.track_settings.deviceId;
} else {
current_device = this._device;
}
return this._videoDevices.find((device) => {
if (device.deviceId === current_device) {
return true;
}
if (device.label === current_device) {
return true;
}
return false;
});
}
/**
* Set device for the scanner.
*/
set device(newDevice) {
// Check if new device is in videoDevicesPromise.
if (this._videoDevices) {
let foundDevice;
// newDevice is an object
if (
Object.prototype.toString.call(newDevice) === '[object Object]' ||
Object.prototype.toString.call(newDevice) === '[object InputDeviceInfo]'
) {
foundDevice = this._videoDevices.find((device) => {
if (newDevice.deviceId && device.deviceId === newDevice.deviceId) {
return true;
}
if (newDevice.label && device.label === newDevice.label) {
return true;
}
return false;
});
}
// newDevice is a string
if (Object.prototype.toString.call(newDevice) === '[object String]') {
foundDevice = this._videoDevices.find((device) => {
if (device.deviceId === newDevice) {
return true;
}
if (device.label === newDevice) {
return true;
}
return false;
});
}
// A device was found.
if (foundDevice) {
this.setAttribute('device', foundDevice.deviceId);
}
} else {
// Default to updating the device if _videoDevices has not been set yet.
this.setAttribute('device', newDevice);
}
}
/**
* Get web_worker for the scanner.
*/
get web_worker() {
return this._web_worker;
}
/**
* Set web_worker for the scanner.
*/
set web_worker(newWebWorker) {
this.setAttribute('web-worker', newWebWorker);
}
/**
* Get line_color for the scanner.
*/
get line_color() {
return this._line_color || this._default_line_color;
}
/**
* Set line_color for the scanner.
*/
set line_color(newLineColor) {
if (HEX_COLOR_REGEX.test(newLineColor)) {
this.setAttribute('line-color', newLineColor);
}
}
/**
* Get line_width for the scanner.
*/
get line_width() {
return this._line_width || this._default_line_width;
}
/**
* Set line_width for the scanner.
*/
set line_width(newLineWidth) {
if (!isNaN(parseInt(newLineWidth)) && parseInt(newLineWidth) > -1) {
this.setAttribute('line-width', parseInt(newLineWidth));
}
}
/**
* Get line_shadow_blur for the scanner.
*/
get line_shadow_blur() {
return this._line_shadow_blur || this._default_line_shadow_blur;
}
/**
* Set line_shadow_blur for the scanner.
*/
set line_shadow_blur(newLineShadowBlur) {
if (
!isNaN(parseInt(newLineShadowBlur)) &&
parseInt(newLineShadowBlur) > -1
) {
this.setAttribute('line-shadow-blur', parseInt(newLineShadowBlur));
}
}
/**
* Get line_shadow_color for the scanner.
*/
get line_shadow_color() {
return this._line_shadow_color || this._default_line_shadow_color;
}
/**
* Set line_shadow_color for the scanner.
*/
set line_shadow_color(newLineShadowColor) {
if (HEX_COLOR_REGEX.test(newLineShadowColor)) {
this.setAttribute('line-shadow-color', newLineShadowColor);
}
}
/**
* Get line_join for the scanner.
*/
get line_join() {
return this._line_join || this._default_line_join;
}
/**
* Set line_join for the scanner.
*/
set line_join(newLineJoin) {
if (STROKE_LINE_JOINS.includes(newLineJoin)) {
this.setAttribute('line-join', newLineJoin);
}
}
/**
* Get line_args for the scanner.
*/
get line_args() {
return {
line_color: this.line_color,
line_width: this.line_width,
line_join: this.line_join,
line_shadow_color: this.line_shadow_color,
line_shadow_blur: this.line_shadow_blur,
};
}
/**
* Get videoDevicesPromise available to scan with.
*/
get videoDevicesPromise() {
return this._videoDevicesPromise;
}
/**
* Get media_track_constraints
*
* Inital argmuents passed to getUserMedia.
*
* Defaults to {video: { facingMode: 'environment'}}
*/
get media_track_constraints() {
if (this._videoDevices) {
if (this.device) {
return { video: { deviceId: { exact: this.device.deviceId } } };
}
}
return { video: { facingMode: 'environment' } };
}
/**
* Reduce constraints based on supported constraints & track capabilities
*
* If a new constraint value is not a part of the supported constraints than
* it will not be included in the return value.
*
* If the current track_capabilities is set then the new constraint value
* will be checked to ensure that it valid. Invalid constraints will not be
* included in the return value.
*
* @param {Object} obj - An Object
* @param {Object} obj.current_constraints - Constraints already set.
* @param {Object} obj.constraints_constant - Constant constraints ie Video or Image etc.
*/
_reduce_constraints({ current_constraints, constraints_constant }) {
const valid_constraints = Object.entries(current_constraints).filter(
([c_prop, c_value]) => {
// Supported constraints.
if (!(c_prop in this._supported_constraints)) {
return false;
}
const c_type = constraints_constant[c_prop];
if (c_type === 'String') {
if (this.track && Object.keys(this.track_capabilities).length > 0) {
if (this.track_capabilities[c_prop]) {
if (this.track_capabilities[c_prop].indexOf(c_value) > -1) {
return true;
}
} else {
return false;
}
} else {
return true;
}
}
if (c_type === 'ConstrainDOMString') {
if (this.track && Object.keys(this.track_capabilities).length > 0) {
if (this.track_capabilities[c_prop]) {
if (this.track_capabilities[c_prop].indexOf(c_value) > -1) {
return true;
}
} else {
return false;
}
} else {
return true;
}
}
if (c_type === 'Object') {
// Check if stringified object.
if (Object.prototype.toString.call(c_value) === '[object String]') {
try {
JSON.parse(c_value);
return true;
} catch (error) {
return false;
}
}
if (Object.prototype.toString.call(c_value) === '[object Object]') {
return true;
}
}
if (c_type === 'ConstrainDouble') {
if (!isNaN(parseFloat(c_value))) {
const c_value_float = parseFloat(c_value);
if (this.track && Object.keys(this.track_capabilities).length > 0) {
if (this.track_capabilities[c_prop]) {
if (
c_value_float >= this.track_capabilities[c_prop].min &&
c_value_float <= this.track_capabilities[c_prop].max
) {
if ('step' in this.track_capabilities[c_prop]) {
if (
c_value_float % this.track_capabilities[c_prop].step ===
0
) {
return true;
}
} else {
return true;
}
}
} else {
return false;
}
} else {
return true;
}
}
}
if (c_type === 'ConstrainULong') {
if (!isNaN(parseInt(c_value))) {
const c_value_float = parseInt(c_value);
if (this.track && Object.keys(this.track_capabilities).length > 0) {
if (this.track_capabilities[c_prop]) {
if (
c_value_float >= this.track_capabilities[c_prop].min &&
c_value_float <= this.track_capabilities[c_prop].max
) {
if ('step' in this.track_capabilities[c_prop]) {
if (
c_value_float % this.track_capabilities[c_prop].step ===
0
) {
return true;
}
} else {
return true;
}
}
} else {
return false;
}
} else {
return true;
}
}
}
if (c_type === 'Boolean') {
if (c_value) {
return true;
} else {
return false;
}
}
}
);
return valid_constraints.reduce((acc, cur) => {
return { ...acc, [cur[0]]: cur[1] };
}, {});
}
/**
* Get image_track_constraints
*
* Extra Image Constraints.
*
*/
get _image_track_constraints() {
let output = {};
Object.entries(IMAGE_TRACK_CONSTRAINTS).map(([c_prop, _]) => {
if (this[`_${c_prop}`] !== undefined) {
output[c_prop] = this[`_${c_prop}`];
}
});
return output;
}
/**
* Valid image_track_constraints
*
*/
get image_track_constraints() {
return this._reduce_constraints({
current_constraints: this._image_track_constraints,
constraints_constant: IMAGE_TRACK_CONSTRAINTS,
});
}
/**
* Get video_track_constraints
*
* Extra Image Constraints.
*
*/
get _video_track_constraints() {
let output = {};
Object.entries(VIDEO_TRACK_CONSTRAINTS).map(([c_prop, _]) => {
if (this[`_${c_prop}`] !== undefined) {
output[c_prop] = this[`_${c_prop}`];
}
});
return output;
}
/**
* Valid video_track_constraints
*
*/
get video_track_constraints() {
return this._reduce_constraints({
current_constraints: this._video_track_constraints,
constraints_constant: VIDEO_TRACK_CONSTRAINTS,
});
}
/**
* Element Map.
*
* @returns {object} Elements within the shadowRoot of this web component.
*/
get _$Map() {
const $s = this.shadowRoot;
return {
$cont: $s.querySelector('#multi-scanner-container'),
$loading_msg: $s.querySelector('#multi-scanner-loading-msg'),
$canvas_output: $s.querySelector('#multi-scanner-canvas-output'),
$video_webcam: $s.querySelector('#multi-scanner-video-webcam'),
};
}
/**
* Draws a box on a canvas given Barcode data.
*
* TODO: Determine if a code can be drawn in a single canvas path.
*
* @method
* @param {Object} obj - An Object
* @param {HTMLCanvasElement} obj.canvas - a canvas element.
* @param {Object} obj.line_args - An Object.
* @param {string} obj.line_args.color - hex color of line, defaults to green.
* @param {int} obj.line_args.width - width of line, defaults to 4.
*/
drawCode({ code, canvas, line_args }) {
// Styling
canvas.lineWidth = line_args.line_width;
canvas.strokeStyle = line_args.line_color;
canvas.shadowColor = line_args.line_shadow_color;
canvas.shadowBlur = line_args.line_shadow_blur;
canvas.lineJoin = line_args.line_join;
// Drawing
canvas.beginPath();
canvas.moveTo(code.cornerPoints[0].x, code.cornerPoints[0].y);
canvas.lineTo(code.cornerPoints[1].x, code.cornerPoints[1].y); // Top
canvas.lineTo(code.cornerPoints[2].x, code.cornerPoints[2].y); // Right
canvas.lineTo(code.cornerPoints[3].x, code.cornerPoints[3].y); // Bottom
canvas.closePath(); // Left ( Links to first point. )
canvas.stroke();
}
/**
* Create a GUID/UUID string.
* Globally/Universally Unique ID.
*
* @method
* @returns {string} String representing a UUID.
*/
guid() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return (
s4() +
s4() +
'-' +
s4() +
'-' +
s4() +
'-' +
s4() +
'-' +
s4() +
s4() +
s4()
);
}
/**
* Applies Constraints to the track/stream.
*
* @method
*/
applyConstraints() {
const constraints = {
...this.image_track_constraints,
// ...this.video_track_constraints,
};
if (this.track) {
this.track.applyConstraints({
advanced: Object.keys(constraints).map((k) => {
return { [k]: constraints[k] };
}),
});
}
}
/**
* When camera is ready.
*
* @method
*/
onCanPlay() {
setTimeout(() => {
this.applyConstraints();
}, this._constraint_delay);
}
/**
* Process the canvas image for Barcodes.
*
* Will run every animation frame refresh.
*
* @returns {Function} - Used inside a requestAnimationFrame call.
*/
async processVideoStream() {
if (
this._$Map.$video_webcam.readyState ===
this._$Map.$video_webcam.HAVE_ENOUGH_DATA
) {
// UI.
this._$Map.$canvas_output.style.display = 'initial';
this._$Map.$video_webcam.style.display = 'initial';
this._$Map.$loading_msg.style.display = 'none';
let vidHeight = this._$Map.$video_webcam.videoHeight;
let vidWidth = this._$Map.$video_webcam.videoWidth;
this._$Map.$canvas_output.setAttribute('height', vidHeight);
this._$Map.$canvas_output.setAttribute('width', vidWidth);
/*
this.canvas_output.drawImage(
this._$Map.$video_webcam,
0,
0,
vidWidth,
vidHeight
);
this.imageData = this.canvas_output.getImageData(
0,
0,
vidWidth,
vidHeight
);
*/
// Could send the video element or ImageData.
this.codes = await this.barcodeDetector.detect(this._$Map.$video_webcam);
// this.codes = await this.barcodeDetector.detect(this.imageData);
if (this.codes.length > 0) {
this.codes.map((code) => {
this.drawCode({
code: code,
canvas: this.canvas_output,
line_args: this.line_args,
});
});
}
// /*
// // TODO: Check Cooldown before on_scan function.
// if (this._cooldown) {
// // TODO: On Scan.
// if (this.on_scan) {
// if (this.on_scan.constructor.name === 'AsyncFunction') {
// await this.on_scan(this.code.data);
// } else {
// this.on_scan(this.code.data);
// }
// }
// // Start cooling.
// if (this.cooldown) {
// this._cooling();
// }
// }
// */
// }
}
this.req_ani = requestAnimationFrame(this.processVideoStream.bind(this));
}
/**
* Start the Scanner.
*
* @method
*/
async start() {
// Exit if already started.
if (this.state === 'started') {
return;
}
// Stop other cameras.
await this.stopAll();
// Establish Video Devices.
if (!this.videoDevicesPromise) {
await this.establishVideoDevices();
}
// Update the GUI.
this._$Map.$canvas_output.style.display = 'initial';
this._$Map.$video_webcam.style.display = 'initial';
// Merge media & video track constraints.
let media_constraints = {};
media_constraints.video = {
...this.media_track_constraints.video,
...this.video_track_constraints,
};
// Start Video Stream.
this.stream = await navigator.mediaDevices.getUserMedia(media_constraints);
// Loading Message
// this._$Map.$s.show();
this._$Map.$video_webcam.srcObject = this.stream;
this._$Map.$loading_msg.innerHTML = '⌛ Loading Scanner...';
// CanPlay
this._$Map.$video_webcam.removeEventListener(
'canplay',
this.onCanPlay.bind(this)
);
this._$Map.$video_webcam.addEventListener(
'canplay',
this.onCanPlay.bind(this)
);
// Before Start
// TODO: WebWoker Canplay.
// TODO: Request Animation Frame.
if (this.worker && window.Worker) {
/*
// TODO: Process WebWoker
// Worker.
// this.worker = new Worker('../static/js/worker/getQR.js');
this.worker = new Worker('./getQR.js');
this.worker.onmessage = (evt) => {
this.code = evt.data;
this.process.call(this);
};
this.worker.onerror = (e) => console.log(e.message, e);
this.video.addEventListener('canplay', () => {
// UI.
this._$Map.$loading_msg.style.display = 'none';
this._$Map.$canvas_output.style.display = 'inital';
this._$Map.$video.style.display = 'inital';
let qr_scanner_css = {
width: '100%',
height: '100%',
};
let scanner_displays = [
this._$Map.$canvas_output,
this._$Map.$video,
];
// Set Displays CSS
scanner_displays.map(($d) => {
Object.entries(qr_scanner_css).map(([k, v]) => {
$d.style[k] = v;
});
});
this.draw();
this.process();
});
*/
} else {
// Tick.
this.req_ani = requestAnimationFrame(this.processVideoStream.bind(this));
}
// Plays Video Camera
this._$Map.$video_webcam.play();
// Change the state attribute & property.
this._state = 'started';
this.setAttribute('state', 'started');
}
/**
* Pause the Scanner.
*
* @method
*/
async pause() {
// Exit if already started.
if (this.state === 'paused') {
return;
}
// Pause the webcam.
this._$Map.$video_webcam.pause();
// Change the state attribute & property.
this._state = 'paused';
this.setAttribute('state', 'paused');
}
/**
* Stop the Scanner.
*
* @method
*/
async stop() {
// Exit if already stopped.
if (this.state === 'stopped') {
return;
}
// Stop All Tracks & remove _stream & _track properties.
if (this.stream) {
this.stream.getTracks().forEach(function (track) {
track.stop();
});
delete this._stream;
delete this._track;
}
// Remove the srcObject
this._$Map.$video_webcam.srcObject = null;
// TODO: Request Animation Frame
if (this.req_ani) {
cancelAnimationFrame(this.req_ani);
delete this.req_ani;
}
/*
// TODO: Terminate Webworker
if (this.worker) {
this.worker.terminate();
}
*/
// Update UI
this._$Map.$loading_msg.innerHTML = 'Scanner Stopped';
this._$Map.$loading_msg.style.display = 'initial';
this._$Map.$canvas_output.style.display = 'none';
// Change the state attribute & property.
this._state = 'stopped';
this.setAttribute('state', 'stopped');
}
/**
* Stop & Start Scanner.
*
* @method
*/
async restart() {
await this.stop();
await this.start();
}
/**
* Stop all of the Cameras.
*
* @method
*/
async stopAll() {
if (Object.keys(this.cameras).length > 0) {
await Promise.all(
Object.entries(this.cameras).map(async ([, camera]) => {
await camera.stop();
})
);
}
}
/**
* Processes ImageData checking for Barcodes.
* This method is synchronous and does not use a WebWorker.
*
* @method
*/
processNonWebWorker() {}
/**
* Processes ImageData checking for Barcodes.
* This method uses a WebWorker.
*
* WebWorker is shared between all MultiScannerElements
*
* @method
*/
processWebWorker() {}
/**
* Returns the videoDevicesPromise promise. If this promise does not
* exist already, it calls requestVideoDevices.
*
* The this.videoDevicesPromise promise resolves an array of all the
* attached devices and is shared across all instances
*
* Ex: this.__prototype__.videoDevicesPromise
*
* @method
* @returns {Promise} - Resolves an Array of attched video devices.
*/
async establishVideoDevices() {
// Resolve the already requested Video Devices.
if (this.videoDevicesPromise) {
try {
const vd = await this.videoDevicesPromise;
if (vd) {
return this.videoDevicesPromise;
}
} catch (e) {
console.log(e);
}
}
this.__proto__._videoDevicesPromise = this.requestVideoDevices();
return this.videoDevicesPromise;
}
/**
* Queries the 'Camera' browser permissions checking if it has been
* previously granted. Preps user with an alert before prompting user
* to allow access for the 'Camera' browser permission if not granted.
*
* Uses getUserMedia & enumerateDevices to get attached hardware and
* sort them by 'videoinput' type.
*
* @method
* @returns {Promise} - Resolves an Array of attched video devices.
*/
async requestVideoDevices() {
let camera_perm;
try {
camera_perm = await navigator.permissions.query({ name: 'camera' });
} catch (e) {
camera_perm = { state: 'denied' };
}
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
throw new Error('enumerateDevices() not supported.');
}
if (camera_perm.state !== 'granted') {
try {
const allow = confirm(this.camera_access_message);
if (!allow) {
throw Error('Denied');
}
} catch (err) {
throw new Error('Camera Permissions Required.');
}
}
try {
/* --- Necessary for getting device 'label' information. */
const st = await navigator.mediaDevices.getUserMedia({ video: true });
st.getTracks().forEach(function (track) {
track.stop();
});
/* --- */
const devices = await navigator.mediaDevices.enumerateDevices();
let video_devices = devices.filter((d) => d.kind === 'videoinput');
this.__proto__._videoDevices = video_devices;
return video_devices;
} catch (err) {
throw new Error('Camera Permissions Required.');
}
}
}
customElements.define('multi-scanner', MultiScannerElement);
export { MultiScannerElement };