Skip to content
Snippets Groups Projects
qr-code-scanner.js 18.8 KiB
Newer Older
import {i18n} from './i18n';
import {css, html} from 'lit-element';
import DBPLitElement from 'dbp-common/dbp-lit-element';
import * as commonStyles from 'dbp-common/styles';
import {ScopedElementsMixin} from '@open-wc/scoped-elements';
import {Icon, MiniSpinner} from 'dbp-common';
import {classMap} from 'lit-html/directives/class-map.js';
import jsQR from "jsqr";

/**
 * Returns the ID for the most important device
 *
 * @param {Map} devices
 * @returns {string|null} the ID
 */
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() {
    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;
    }
}


/**
 * Notification web component
 */
export class QrCodeScanner extends ScopedElementsMixin(DBPLitElement) {
    constructor() {
        super();
        this.lang = 'de';
        this.askPermission = false;
        this.videoRunning = false;
        this.front = false;
        this.loading = false;
        this.scanIsOk = false;
        this.showOutput = false;
        this.stopScan = false;
        this.sourceChanged = false;
        this.clipMask = false;
        this._devices = new Map();
    }

    static get scopedElements() {
        return {
            'dbp-icon': Icon,
            'dbp-mini-spinner': MiniSpinner,
        };
    }

    /**
     * See: https://lit-element.polymer-project.org/guide/properties#initialize
     */
    static get properties() {
        return {
            lang: { type: String },
            askPermission: { type: Boolean, attribute: false },
            videoRunning: { type: Boolean, attribute: false },
            front: { type: Boolean, attribute: false },
            loading: { type: Boolean, attribute: false },
            scanIsOk: { type: Boolean, attribute: 'scan-is-ok' },
            showOutput: { type: Boolean, attribute: 'show-output' },
            stopScan: { type: Boolean, attribute: 'stop-scan' },
            activeCamera: { type: String, attribute: false },
            sourceChanged: { type: Boolean, attribute: false },
            clipMask: { type: Boolean, attribute: 'clip-mask' },
            _devices: { type: Map, attribute: false},
            _loadingMessage: { type: String, attribute: false },
        };
    }

    connectedCallback() {
        super.connectedCallback();
        i18n.changeLanguage(this.lang);

        this.updateComplete.then(async ()=>{
            this._loadingMessage = i18n.t('no-camera-access');
            let devices = await getVideoDevices();
            this.activeCamera = getPrimaryDevice(devices) || '';
            this._devices = devices;
                this.startScanning();
    disconnectedCallback() {
        this.stopScanning();
        super.disconnectedCallback();
    }

    updated(changedProperties) {
        if (changedProperties.get('stopScan') && !this.stopScan) {
            this.startScanning();
    /**
     * Init and start the video and QR code scan
     *
     */
    async startScanning() {
        this.askPermission = true;

        let video = document.createElement("video");
        let canvasElement = this._("#canvas");
        let canvas = canvasElement.getContext("2d");
        let loadingMessage = this._("#loadingMessage");
        let outputContainer = this._("#output");
        let outputMessage = this._("#outputMessage");
        let outputData = this._("#outputData");
        let qrContainer = this._("#qr");
        let scroll = false;
        let color = this.scanIsOk ? getComputedStyle(this)
                .getPropertyValue('--dbp-success-bg-color') : getComputedStyle(this)
            .getPropertyValue('--dbp-danger-bg-color');

        function drawLine(begin, end, color) {
            canvas.beginPath();
            canvas.moveTo(begin.x, begin.y);
            canvas.lineTo(end.x, end.y);
            canvas.lineWidth = 4;
            canvas.strokeStyle = color;
            canvas.stroke();
        }

        let videoId = this.activeCamera;
        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" } };
        let stream = null;
        try {
            stream = await navigator.mediaDevices.getUserMedia(constraint);
        } catch(e) {
            console.log(e);
            this.askPermission = true;
        }
        if (stream !== null) {
            video.srcObject = stream;
            video.setAttribute("playsinline", true); // required to tell iOS safari we don't want fullscreen
            video.play();
            this.videoRunning = true;
            qrContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
            if (this._requestID !== null) {
                cancelAnimationFrame(this._requestID);
                this._requestID = null;
            console.assert(this._requestID === null);
            this._requestID = requestAnimationFrame(tick);
        }
        let lastVideoScanTime = -1;
        let lastSentData = null;
        function tick() {
           if (that.sourceChanged) {
                video.srcObject.getTracks().forEach(function(track) {
                    track.stop();
                    console.log("Changed Media");
                });
                that.sourceChanged = false;
                that.startScanning();
            if (that.videoRunning === false) {
                video.srcObject.getTracks().forEach(function(track) {
                    track.stop();
                    loadingMessage.hidden = false;
                    canvasElement.hidden = true;
                    outputContainer.hidden = true;
                });
                that._loadingMessage = i18n.t('no-camera-access');
            if (that.stopScan) {
                video.srcObject.getTracks().forEach(function(track) {
                    track.stop();
                    console.log("stop early");
                    loadingMessage.hidden = false;
                    canvasElement.hidden = true;
                    outputContainer.hidden = true;
                });
                that._loadingMessage = i18n.t('finished-scan');
                return;
            }
            that.loading = true;
            that._loadingMessage = i18n.t('loading-video');
            if (video.readyState === video.HAVE_ENOUGH_DATA) {
                loadingMessage.hidden = true;
                that.loading = false;
                canvasElement.hidden = false;
                outputContainer.hidden = false;

                canvasElement.height = video.videoHeight;
                canvasElement.width = video.videoWidth;
                canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height);
                let maskWidth = canvasElement.width;
                let maskHeight = canvasElement.height;
                let maskStartX = 0;
                let maskStartY = 0;
                if (that.clipMask) {
                    //draw mask
                    let clip = canvasElement.width > canvasElement.height ? canvasElement.height/4 * 3 : canvasElement.width/4 * 3;
                    maskWidth = clip < 250 ? 250 : clip;
                    maskHeight = clip < 250 ? 250 : clip;
                    clip = clip < 250 ? 250 : clip;
                    maskStartX = canvasElement.width/2 - maskWidth/2;
                    maskStartY = canvasElement.height/2 - maskHeight/2;

                    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();
                    canvas.fillStyle = "white";
                    canvas.moveTo(maskStartX,maskStartY);
                    canvas.rect(maskStartX, maskStartY, clip/3, 10);
                    canvas.rect(maskStartX, maskStartY, 10, clip/3);

                    canvas.rect(maskStartX + clip/3*2, maskStartY, clip/3, 10);
                    canvas.rect(maskStartX + clip - 10, maskStartY, 10, clip/3);

                    canvas.rect(maskStartX, maskStartY + clip -10, clip/3, 10);
                    canvas.rect(maskStartX, maskStartY + clip/3*2, 10, clip/3);

                    canvas.rect(maskStartX + clip/3*2, maskStartY + clip -10, clip/3, 10);
                    canvas.rect(maskStartX + clip - 10, maskStartY + clip/3*2, 10, clip/3);

                    canvas.fill();
                let code = null;
                // We only check for QR codes 5 times a second to improve performance
                let shouldAnalyze = Math.abs(lastVideoScanTime - video.currentTime) >= 1/3;
                    lastVideoScanTime = video.currentTime;
                    let imageData = canvas.getImageData(maskStartX, maskStartY, maskWidth, maskHeight);
                    code = jsQR(imageData.data, imageData.width, imageData.height, {
                        inversionAttempts: "dontInvert",
                    });
                    lastCode = code;
                } else {
                    code = lastCode;
                }

                if (code) {
                    let topLeftCorner = {x: 0, y: 0};
                    let topRightCorner = {x: 0, y: 0};
                    let bottomRightCorner = {x: 0, y: 0};
                    let bottomLeftCorner = {x: 0, y: 0};

                    if (that.clipMask) {
                        topLeftCorner.x = code.location.topLeftCorner.x + maskStartX;
                        topLeftCorner.y = code.location.topLeftCorner.y + maskStartY;
                        topRightCorner.x = code.location.topRightCorner.x + maskStartX;
                        topRightCorner.y = code.location.topRightCorner.y + maskStartY;
                        bottomRightCorner.x = code.location.bottomRightCorner.x + maskStartX;
                        bottomRightCorner.y = code.location.bottomRightCorner.y + maskStartY;
                        bottomLeftCorner.x = code.location.bottomLeftCorner.x + maskStartX;
                        bottomLeftCorner.y = code.location.bottomLeftCorner.y + maskStartY;
                    }
                    else {
                        topLeftCorner = code.location.topLeftCorner;
                        topRightCorner = code.location.topRightCorner;
                        bottomRightCorner = code.location.bottomRightCorner;
                        bottomLeftCorner = code.location.bottomLeftCorner;
                    }

                    drawLine(topLeftCorner, topRightCorner, color);
                    drawLine(topRightCorner, bottomRightCorner, color);
                    drawLine(bottomRightCorner, bottomLeftCorner, color);
                    drawLine(bottomLeftCorner, topLeftCorner, color);

                    outputMessage.hidden = true;
                    outputData.parentElement.hidden = false;
                    outputData.innerText = code.data;
                    if (lastSentData !== code.data)
                        that.sendUrl(code.data);
                    lastSentData = code.data;
                } else {
                    outputMessage.hidden = false;
                    outputData.parentElement.hidden = true;
                }
            }
            if (video.readyState === video.HAVE_ENOUGH_DATA && !scroll) {
                qrContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
                scroll = true;
            }
            console.assert(that._requestID === null);
            that._requestID = requestAnimationFrame(tick);
    /**
     * Update if video source is changed
     *
     * @param e
     */
    updateSource(e) {
        this.activeCamera = e.srcElement.value;
        this.sourceChanged = true;
    }

    /**
     * Stops the active video and scan process
     *
     */
    stopScanning() {
        this.askPermission = false;
        this.videoRunning = false;
    }

    /**
     * Sends an event with the data which is detected from QR code reader
     *
     * @param data
     */
    sendUrl(data) {
       const event = new CustomEvent("dbp-qr-code-scanner-data",
            {  bubbles: true, composed: true , detail: data});
        this.dispatchEvent(event);
    }

    static get styles() {
        // language=css
        return css`
            ${commonStyles.getThemeCSS()}
            ${commonStyles.getGeneralCSS()}
            ${commonStyles.getSelect2CSS()}
            ${commonStyles.getButtonCSS()}
            
            #loadingMessage {
                text-align: center;
                padding: 40px;
            }
            
            .wrapper-msg {
                width: 100%;
                display: flex;
                justify-content: center;
                align-items: baseline;
                  margin-top: 20px;
                  background: #eee;
                  padding: 10px;
                  padding-bottom: 0;
                  padding-bottom: 10px;
                  word-wrap: break-word;
                text-align: center;
            }
            
            .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;
            }
                background: calc(100% - 0.2rem) center no-repeat url("https://mw-frontend-dev.tugraz.at/apps/checkin/local/dbp-common/icons/chevron-down.svg");
                background-position-x: calc(100% - 0.4rem);
            select:not(.select)#videoSource{
                background-size: auto 45%;
                margin-top: 2rem;
                padding-top: 2rem;
                border-top: 1px solid black;
            
            @media only screen
            and (orientation: portrait)
            and (max-device-width: 765px) {   
                .button-wrapper{    
                    display: flex;
                   justify-content: space-between;
                }
            }
        let hasDevices = this._devices.size > 0;

        return html`
            <div class="columns">
                <div class="column" id="qr">
                    <div class="${classMap({hidden: !hasDevices})}">
                        <div class="button-wrapper">
                            <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.updateSource}>
                                ${Array.from(this._devices).map(item => html`<option value="${item[0]}">${item[1]}</option>`)}
                            </select>
                        <div id="loadingMessage" class=" ${classMap({hidden: !this.askPermission})}">
                            <div class="wrapper-msg">
                                <dbp-mini-spinner class="spinner ${classMap({hidden: !this.loading})}"></dbp-mini-spinner>
                                <div class="loadingMsg">${this._loadingMessage}</div>
                       <canvas id="canvas" hidden class=""></canvas>
                        <pre id="error"></pre>
                        <div id="output" hidden class=" ${classMap({hidden: !this.showOutput})}">
                           <div id="outputMessage">${i18n.t('no-qr-detected')}</div>
                           <div hidden><b>${i18n.t('data')}:</b> <span id="outputData"></span></div>
                        </div>
                    </div>
                    <div class="${classMap({hidden: hasDevices})}">
                        ${i18n.t('no-support')}