Newer
Older
import {createInstance} from './i18n';
import DBPLitElement from '@dbp-toolkit/common/dbp-lit-element';
import * as commonStyles from '@dbp-toolkit/common/styles';
import {ScopedElementsMixin} from '@open-wc/scoped-elements';
import {Icon, MiniSpinner} from '@dbp-toolkit/common';
import {classMap} from 'lit/directives/class-map.js';
import * as commonUtils from '@dbp-toolkit/common/utils';
import {Mutex} from 'async-mutex';
import {name as pkgName} from './../package.json';
/**
* Returns the ID for the most important device
*
* @param {Map} devices
*/
function getPrimaryDevice(devices) {
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
if (devices.has('environment'))
return 'environment';
}
if (devices.size) {
return Array.from(devices)[0][0];
}
return null;
}
/**
* Returns a map of device IDs and translated names.
*
* Moreimportant devices first.
*
* @returns {Map<string,string>} the map of devices
async function getVideoDevices(i18n) {
let devices_map = new Map();
if (navigator.mediaDevices
&& navigator.mediaDevices.enumerateDevices
&& navigator.mediaDevices.getUserMedia) {
let devices;
try {
devices = await navigator.mediaDevices.enumerateDevices();
} catch (err) {
console.log(err.name + ": " + err.message);
return devices_map;
}
for (let device of devices) {
if (device.kind === 'videoinput') {
let id = device.deviceId;
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
devices_map.set('environment', i18n.t('back-camera'));
devices_map.set('user', i18n.t('front-camera'));
} else {
devices_map.set(id ? id : true, device.label || i18n.t('camera') + (devices_map.size + 1));
}
}
}
return devices_map;
} else {
return devices_map;
}
}
/**
* Checks if user Agent is IOS, but not Safari browser
*
* @param {string} devices_map
* @returns {object|null} a video element or null
*/
function checkIosMobileSupport(devices_map) {
return /(iPhone|iPad|iPod).*(CriOS|FxiOS|OPT|EdgiOS|YaBrowser|AlohaBrowser)/i.test(navigator.userAgent);
/**
* @param {string} deviceId
* @returns {object|null} a video element or null
*/
async function createVideoElement(deviceId) {
let videoId = deviceId;
let constraint = { video: { deviceId: videoId } };
if ( (videoId === 'environment' || videoId === '') ) {
console.log("vid:", videoId);
constraint = { video: { facingMode: "environment" } };
} else if ( videoId === 'user' ) {
console.log("vid2:", videoId);
constraint = { video: { facingMode: "user", mirrored: true } };
}
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia(constraint);
} catch(e) {
console.log(e);
}
if (stream !== null) {
let video = document.createElement("video");
video.srcObject = stream;
return video;
}
return null;
}
class QRScanner {
constructor() {
this._engine = null;
this._canvas = document.createElement("canvas");
this._scanner = null;
}
async scan(canvas, x, y, width, height) {
if (this._scanner === null) {
this._scanner = (await import('qr-scanner')).default;
this._scanner.WORKER_PATH = commonUtils.getAssetURL(pkgName, 'qr-scanner-worker.min.js');
}
if (this._engine === null) {
this._engine = await this._scanner.createQrEngine(this._scanner.WORKER_PATH);
return {data: await this._scanner.scanImage(canvas, {x: x, y: y, width: width, height: height}, this._engine, this._canvas)};
} catch (e) {
return null;
}
}
}
export class QrCodeScanner extends ScopedElementsMixin(DBPLitElement) {
this._i18n = createInstance();
this.lang = this._i18n.language;
this._askPermission = false;
this._loading = false;
this._activeCamera = '';
this._devices = new Map();
this._requestID = null;
this._loadingMessage = '';

Reiter, Christoph
committed
this.matchRegex = '.*';
this._videoElement = null;
this._videoRunning = false;
this._lock = new Mutex();
}
static get scopedElements() {
return {
'dbp-icon': Icon,
'dbp-mini-spinner': MiniSpinner,
};
}
static get properties() {
return {
lang: { type: String },
showOutput: { type: Boolean, attribute: 'show-output' },
stopScan: { type: Boolean, attribute: 'stop-scan' },
matchRegex: { type: String, attribute: 'match-regex' },
_activeCamera: { type: String, attribute: false },
_loading: { type: Boolean, attribute: false },
_devices: { type: Map, attribute: false},
_loadingMessage: { type: String, attribute: false },
_outputData: { type: String, attribute: false },
_askPermission: { type: Boolean, attribute: false },
_videoRunning: { type: Boolean, attribute: false },
async connectedCallback() {
let devices = await getVideoDevices(this._i18n);
this._activeCamera = getPrimaryDevice(devices) || '';
this._devices = devices;
if (!this.stopScan) {
await this.startScanning();
}
async disconnectedCallback() {
await this.stopScanning();
super.disconnectedCallback();
}
updated(changedProperties) {
if (changedProperties.get('stopScan') && !this.stopScan) {
} else if (!changedProperties.get('stopScan') && this.stopScan) {
this.stopScanning();
}
update(changedProperties) {
changedProperties.forEach((oldValue, propName) => {
switch (propName) {
case "lang":
this._i18n.changeLanguage(this.lang);
break;
}
});
super.update(changedProperties);
}
/**
* Init and start the video and QR code scan
*/
await this.stopScanning();
const release = await this._lock.acquire();
try {
await this._startScanning();
} finally {
release();
}
}
async _startScanning() {
const i18n = this._i18n;
console.assert(this._lock.isLocked());
await this.updateComplete;
let targetCanvas = this._("#canvas");
let targetvideo = this._("#video");
let canvasElement = document.createElement("canvas");
let firstDrawDone = false;
this._askPermission = true;
this._loadingMessage = html` ${i18n.t('no-camera-access')} <br> ${i18n.t('check-access')}`;
let video = await createVideoElement(this._activeCamera);
if ( video !== null ) {
targetvideo.appendChild(video);
}
this._askPermission = false;
let lastCode = null;
let detector = new QRScanner();
let detectorRunning = false;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
// draw into a temporary canvas first
canvasElement.height = video.videoHeight;
canvasElement.width = video.videoWidth;
let canvas = canvasElement.getContext("2d");
canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);
let maskWidth = canvasElement.width;
let maskHeight = canvasElement.height;
let maskStartX = 0;
let maskStartY = 0;
let maxSize = canvasElement.width > canvasElement.height ? canvasElement.height/4 * 3 : canvasElement.width/4 * 3;
console.assert(maxSize <= canvasElement.width && maxSize <= canvasElement.height);
maskWidth = maxSize;
maskHeight = maxSize;
maskStartX = canvasElement.width/2 - maskWidth/2;
maskStartY = canvasElement.height/2 - maskHeight/2;
let lastVideo = video;
if (!detectorRunning) {
detectorRunning = true;
detector.scan(canvasElement, maskStartX, maskStartY, maskWidth, maskHeight).then((code) => {
detectorRunning = false;
// if we got restarted then the video element is new, so stop then.
if (lastVideo !== this._videoElement)
return;
lastCode = code;
if (code) {
if (lastSentData !== code.data) {
this._outputData = code.data;
this.dispatchEvent(new CustomEvent("code-detected",
{bubbles: true, composed: true, detail: {'code': code.data}}));
}
lastSentData = code.data;
} else {
this._outputData = null;
lastSentData = null;
}
});
let matched = lastCode ? lastCode.data.match(this.matchRegex) !== null : false;

Reiter, Christoph
committed
//draw mask
canvas.beginPath();
canvas.fillStyle = "#0000006e";
canvas.moveTo(0,0);
canvas.lineTo(0, canvasElement.height);
canvas.lineTo( canvasElement.width, canvasElement.height);
canvas.lineTo( canvasElement.width,0);
canvas.rect(maskStartX, maskStartY, maskWidth, maskHeight);
canvas.fill();
canvas.beginPath();
let okColor = getComputedStyle(this).getPropertyValue('--dbp-success-dark');
let notOkColor = getComputedStyle(this).getPropertyValue('--dbp-danger-dark');

Reiter, Christoph
committed
canvas.fillStyle = matched ? okColor : notOkColor;

Reiter, Christoph
committed
canvas.fillStyle = 'white';

Reiter, Christoph
committed
let borderWidth = Math.max(maskWidth, maskHeight) / 50;
canvas.moveTo(maskStartX,maskStartY);
canvas.rect(maskStartX, maskStartY, maskWidth/3, borderWidth);
canvas.rect(maskStartX, maskStartY, borderWidth, maskHeight/3);
canvas.rect(maskStartX + maskWidth/3*2, maskStartY, maskWidth/3, borderWidth);
canvas.rect(maskStartX + maskWidth - borderWidth, maskStartY, borderWidth, maskHeight/3);
canvas.rect(maskStartX, maskStartY + maskHeight - borderWidth, maskWidth/3, borderWidth);
canvas.rect(maskStartX, maskStartY + maskHeight/3*2, borderWidth, maskHeight/3);
canvas.rect(maskStartX + maskWidth/3*2, maskStartY + maskHeight - borderWidth, maskWidth/3, borderWidth);
canvas.rect(maskStartX + maskWidth - borderWidth, maskStartY + maskHeight/3*2, borderWidth, maskHeight/3);
canvas.fill();
targetCanvas.height = canvasElement.height;
targetCanvas.width = canvasElement.width;
targetCanvas.getContext("2d").drawImage(canvasElement, 0, 0);
if (!firstDrawDone) {
this.dispatchEvent(new CustomEvent("scan-started", {bubbles: true, composed: true}));
firstDrawDone = true;
}
console.assert(this._requestID === null);
this._requestID = requestAnimationFrame(tick);
};
if (video !== null) {
video.setAttribute("playsinline", true); // required to tell iOS safari we don't want fullscreen
this._videoRunning = true;
console.assert(this._requestID === null);
this._videoElement = video;
this._loading = true;
this._loadingMessage = i18n.t('loading-video');
this._requestID = requestAnimationFrame(tick);
/**
* Update if video source is changed
*
* @param e
*/
this._activeCamera = e.srcElement.value;
await this.stopScanning();
await this.startScanning();
console.log("Changed Media");
/**
* Stops the active video and scan process
*
*/
const release = await this._lock.acquire();
try {
if (this._videoElement !== null) {
let video = this._videoElement;
video.srcObject.getTracks().forEach(function(track) {
track.stop();
});
this._videoElement = null;
}
if (this._requestID !== null) {
cancelAnimationFrame(this._requestID);
this._requestID = null;
}
this._askPermission = false;
this._videoRunning = false;
this._loading = false;
this._loadingMessage = '';
} finally {
release();
}
static get styles() {
// language=css
return css`
${commonStyles.getThemeCSS()}
${commonStyles.getGeneralCSS()}
${commonStyles.getButtonCSS()}
text-align: center;
padding: 40px;
}
.wrapper-msg {
width: 100%;
display: flex;
justify-content: center;
align-items: baseline;
margin-top: 2rem;
max-height: calc(100vh - 100px);
max-width: 100%;
background: var(--dbp-text-muted-light);
color: var(--dbp-text-dark);
padding: 10px;
padding-bottom: 0;
padding-bottom: 10px;
word-wrap: break-word;
.spinner{
margin-right: 10px;
font-size: 0.7em;
}
#videoSource{
padding-bottom: calc(0.375em - 2px);
padding-left: 0.75em;
padding-right: 1.75em;
padding-top: calc(0.375em - 2px);
background-position-x: calc(100% - 0.4rem);
font-size: inherit;
}
select:not(.select)#videoSource{
background-size: auto 45%;
margin-top: 2rem;
padding-top: 2rem;
#video video, #video{
height: 0px;
width: 0px;
opacity: 0;
}
@media only screen
and (orientation: portrait)
.button-wrapper{
display: flex;
justify-content: space-between;
}
}
const i18n = this._i18n;
let hasDevices = this._devices.size > 0;
let showCanvas = this._videoRunning && !this._askPermission && !this._loading;
let noSupportString = checkIosMobileSupport(this._devices) ? i18n.t('no-ios-support') : i18n.t('no-support');
return html`
<div class="columns">
<div class="column">
<div class="${classMap({hidden: !hasDevices})}">
<button class="start button is-primary ${classMap({hidden: this._videoRunning})}" @click="${() => this.startScanning()}" title="${i18n.t('start-scan')}">${i18n.t('start-scan')}</button>
<button class="stop button is-primary ${classMap({hidden: !this._videoRunning})}" @click="${() => this.stopScanning()}" title="${i18n.t('stop-scan')}">${i18n.t('stop-scan')}</button>
<select id="videoSource" class="button" @change=${this._onUpdateSource}>
${Array.from(this._devices).map(item => html`<option value="${item[0]}">${item[1]}</option>`)}
</select>
<div id="loadingMessage" class="${classMap({hidden: showCanvas})}">
<dbp-mini-spinner class="spinner ${classMap({hidden: !this._loading})}"></dbp-mini-spinner>
<div class="loadingMsg">${this._loadingMessage}</div>
<canvas id="canvas" class="${classMap({hidden: !showCanvas})}"></canvas>
<div id="video"></div>
<div class="output ${classMap({hidden: !(this.showOutput && showCanvas)})}">
${ (this._outputData !== null) ? html`
<div><b>${i18n.t('data')}:</b> <span>${this._outputData}</span></div>
` : html`
<div>${i18n.t('no-qr-detected')}</div>
`}
<div class="${classMap({hidden: hasDevices})}">
${noSupportString}
</div>
</div>
`;
}
}