Repository

js/Mobilizing/input/UserMedia.js

import Component from '../core/Component';
import * as debug from '../core/util/Debug';

/**
* UserMedia class give access to navigator.mediaDevices.getUserMedia with various simplification of access
*/
export default class UserMedia extends Component {
    /**
    * @param {Object} params Parameters object, given by the constructor.
    * @param {Object} params.constraints getUserMedia constraints (https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints)
    * @param {String} params.deviceKind "videoinput" || "audioinput"
    * @param {String} params.deviceLabel the label given by the device when the brower has autorizations to access it before users say so with authorization dialogue box
    * @param {Boolean} params.useNativeResolution default=false, if true, the native resolution for the video device will be automatically searched and applied to video element and canvas element
    * @param {Boolean} params.createElement default=true, if true, will generate a <video> element internally for simple display of the video stream (requiered anyway on iOS)
    * @param {Boolean} params.createCanvas default=true, if true, will generate a <canvas> element internally for frame by frame pixels operation. the canvas should be accessed in the callback
    * @param {Function} params.callback the function called back when the "canplay" event is triggered by the stream after opening. This is where you can do what ever you want with the stream, through videoEl or canvas
    */
    constructor({
        constraints = undefined,
        deviceKind = "videoinput",
        deviceLabel = "",
        useNativeResolution = false,
        createChoiceMenu = true,
        createElement = true,
        createCanvas = true,
        callback = undefined
    } = {}) {
        super(...arguments);

        this.constraints = constraints;
        this.deviceKind = deviceKind;
        this.deviceLabel = deviceLabel;
        this.useNativeResolution = useNativeResolution;
        this.createChoiceMenu = createChoiceMenu;
        this.createElement = createElement;
        this.createCanvas = createCanvas;
        this.callback = callback;

        /**
        * the current device to be opened
        * @private
        */
        this.deviceToOpen = null;

        /**
        * the current device video stream reference
        */
        this.videoStream = null;

        /**
        * the current device audio stream reference
        */
        this.audioStream = null;
    }

    /**
    * Set the UserMedia up.
    */
    setup() {
        if (!this._setupDone) {

            //prepare for the video input stream use
            if (this.deviceKind === "videoinput") {
                //creates an html video element to send the stream to and display it
                if (this.createElement || this.createCanvas) {
                    /**
                    * the video element to which the video stream is attached to
                    * @property videoEl
                    */
                    this.videoEl = document.createElement("video");
                    this.videoEl.autoplay = true;
                    this.videoEl.setAttribute("playsinline", "");//to activate rendering on iOS
                    this.videoEl.setAttribute("muted", "");

                    //Safari multiple fucking bugs : video element MUST be on screen to be used for pixels copy to a canvas
                    document.body.appendChild(this.videoEl);
                    this.videoEl.style.position = "absolute";
                    this.videoEl.style.top = "0px";
                    this.videoEl.style.opacity = "0";
                }

                if (this.createCanvas) {
                    if (this.deviceKind === "videoinput") {
                        /**
                        * the prefortatted canvas element into which the video stream can be drawn.
                        * this canvas has the same size has the video stream
                        * @property canvas
                        * */
                        this.canvas = document.createElement("canvas");
                        //other props will be given later
                    }
                }
            }

            //simplify debbuging access to present devices

            /**
            * List of all available devices
            * */
            this.devices = [];
            /**
            * List of all available audio devices
            * */
            this.audioDevices = [];
            /**
            * List of all available video devices
            * */
            this.videoDevices = [];

            //build the getUserMedia instruction
            if (this.constraints) {

                if (this.deviceKind === "videoinput") {

                    navigator.mediaDevices.getUserMedia(this.constraints)
                        .then(this.videoStreamOpened.bind(this))
                        .catch((err) => {
                            debug.error(`${err.name}: ${err.message}`);
                        }); // always check for errors at the end

                }
                else if (this.deviceKind === "audioinput") {

                    navigator.mediaDevices.getUserMedia(this.constraints)
                        .then(this.audioStreamOpened.bind(this))
                        .catch((err) => {
                            debug.error(`${err.name}: ${err.message}`);
                        }); // always check for errors at the end

                }
            }
            else {
                //no constraints, search for all devices
                (async () => {
                    //needed to get user authorization to use the device
                    await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
                    navigator.mediaDevices.enumerateDevices()
                        .then(this.devicesFound.bind(this))
                        .catch(this.streamError);
                })();
            }

            super.setup();
        }
    }

    /**
    * Activates the component
    */
    on() {
        super.on();

        //open the device to get its stream
        if (this.deviceToOpen) {
            this.openDevice(this.deviceToOpen);
        }
    }

    /**
    * Deactivate the component
    */
    off() {
        super.off();
        //@TODO stop the streams
    }

    /**
    * Use internally to find the best device according to the infos given by the user
    * @private
    * @param {Object} devices object given by the Promise launched by enumerateDevices()
    */
    devicesFound(devices) {
        this.devices = devices;

        //organize devices by types in sub arrays
        devices.forEach((device) => {
            if (device.kind === "audioinput") {
                this.audioDevices.push(device);
            }
            else if (device.kind === "videoinput") {
                this.videoDevices.push(device);
            }
        });

        debug.log("devices", this.devices, "audio devices", this.audioDevices, "video devices", this.videoDevices);

        //user wants to choose explicitly from the generated list
        if (this.createChoiceMenu) {

            this.choiceMenu = document.createElement("select");
            this.choiceMenu.id = "AVselectMenu";
            this.choiceMenu.style.position = "fixed";
            this.choiceMenu.style.top = "10px";
            this.choiceMenu.style.left = "10px";
            document.body.appendChild(this.choiceMenu);

            if (this.deviceKind === "videoinput") {
                const videoGroup = document.createElement("optgroup");
                videoGroup.label = "Video Devices";
                this.choiceMenu.appendChild(videoGroup);

                this.videoDevices.forEach((device) => {
                    const videoDevice = document.createElement("option");
                    videoDevice.value = device.deviceId;
                    videoDevice.innerText = device.label;
                    videoGroup.appendChild(videoDevice);
                });
            }

            if (this.deviceKind === "audioinput") {
                const audioGroup = document.createElement("optgroup");
                audioGroup.label = "Audio Devices";
                this.choiceMenu.appendChild(audioGroup);

                this.audioDevices.forEach((device) => {
                    const audioDevice = document.createElement("option");
                    audioDevice.value = device.deviceId;
                    audioDevice.innerText = device.label;
                    audioGroup.appendChild(audioDevice);
                });
            }

            this.choiceMenu.selectedIndex = -1;

            this.choiceMenu.addEventListener("change", (event) => {
                const selected = event.target.options[event.target.selectedIndex];
                const tempDevice = {
                    deviceId: selected.value,
                    kind: this.deviceKind,
                    label: selected.innerText,
                };
                debug.log(selected, "tempDevice", tempDevice);
                this.deviceToOpen = tempDevice;
                this.openDevice(this.deviceToOpen);

                event.target.style.display = "none";
            });

        }
        else {
            devices.forEach((device) => {
                //special case for default label, that is in deviceId in the constraints
                if (this.deviceLabel === "default") {
                    if (device.label !== "" &&
                        device.deviceId === this.deviceLabel &&
                        device.kind === this.deviceKind) {
                        this.deviceToOpen = device;
                    }
                    //we don't have the exact name, just take the 1st input of the same kind
                    else if (device.kind === this.deviceKind) {
                        this.deviceToOpen = device;
                    }
                }
                //if we gave a specific label (checked with enumerate device)
                else {
                    if (device.label !== "" && device.label === this.deviceLabel) {
                        if (device.kind === this.deviceKind) {
                            this.deviceToOpen = device;
                        }
                    }
                }
            });

            debug.log("this.deviceToOpen ", this.deviceToOpen);

            if (this.deviceToOpen) {
                this.openDevice(this.deviceToOpen);
            }
        }

    }

    /**
    * open the device that has been found by devicesFound()
    * @private
    * @param {Object} device the device to open
    */
    openDevice(device) {
        debug.log("opening device", device);

        if (!this.constraints) {
            this.constraints = {};
        }

        debug.log("this.constraints", this.constraints);

        if (device.kind === "audioinput") {
            this.constraints.audio = {
                deviceId: device.deviceId,
                groupId: device.groupId
            };

            //this.constraints.video = false;
            debug.log("this.constraints", this.constraints);

            navigator.mediaDevices.getUserMedia(this.constraints).then(
                this.audioStreamOpened.bind(this)
            ).catch(
                this.streamError.bind(this)
            );
        }
        else if (device.kind === "videoinput") {
            this.constraints.video = {
                deviceId: device.deviceId,
                groupId: device.groupId
            };

            //this.constraints.video = false;
            debug.log("this.constraints", this.constraints);


            navigator.mediaDevices.getUserMedia(this.constraints).then(
                this.videoStreamOpened.bind(this)
            ).catch(
                this.streamError.bind(this)
            );
        }
    }

    /**
    * success callback when requesting audio input stream
    * @callback videoStreamOpened
    * @param {Object} stream the incoming stream to be used
    */
    videoStreamOpened(stream) {
        this.videoStream = stream;

        //get the device capabilities through the video stream
        //gives access to stream capabilities for use in the callback
        /**
        * stream actual capabalities, for debugging purpose
        * @property {Object} streamCapabilities
        * */
        this.streamCapabilities = stream.getVideoTracks()[0].getCapabilities();

        /**
        * stream actual settings, for debugging purpose
        * @property {Object} streamSettings
        * */
        this.streamSettings = stream.getVideoTracks()[0].getSettings();

        //check if its a video stream (that have a width)
        //&& if the user wants to automatically **use native resolution** of the camera
        if (this.streamCapabilities.width && this.useNativeResolution) {
            //grab what we need from device capabilities
            const maxWidth = this.streamCapabilities.width.max;
            const maxHeight = this.streamCapabilities.height.max;
            const deviceId = this.streamCapabilities.deviceId;

            //compare settings to capabilities
            if (this.streamSettings.width !== maxWidth || this.streamSettings.height !== maxHeight) {
                //if we already have a constraints object, update it!
                if (this.constraints) {
                    //we have a constraints object, but no video object, build it!
                    if (typeof this.constraints.video !== "object") {
                        this.constraints.video = {};
                    }
                    //build the new constrains from streamCapabilities
                    this.constraints.video.width = maxWidth;
                    this.constraints.video.height = maxHeight;
                    this.constraints.video.deviceId = deviceId;

                }
                else {
                    //no constraints, build it!
                    this.constraints = { "video": {} };

                    this.constraints.video.width = maxWidth;
                    this.constraints.video.height = maxHeight;
                    this.constraints.video.deviceId = deviceId;
                }

                console.log("new : constraints ", this.constraints);

                //apply to the videoEl attributes
                this.videoEl.width = maxWidth;
                this.videoEl.height = maxHeight;

                //stop the stream to relaunch it in a clean way
                stream.getVideoTracks()[0].stop();

                //relaunch the same device with new constrains
                navigator.mediaDevices.getUserMedia(this.constraints)
                    .then(this.videoStreamOpened.bind(this))
                    .catch((err) => {
                        debug.log(`${err.name}: ${err.message}`);
                    }); // always check for errors at the end.;
            }
        }

        if (this.createElement || this.createCanvas) {
            //this.videoEl.setAttribute("muted", "");

            //apply the stream to the videoEl
            this.videoEl.srcObject = stream;

            this.videoEl.addEventListener("canplay", () => {

                //if we have a canvas, it should have the same size as the videoEl inner video stream
                if (this.createCanvas) {
                    this.canvas.width = this.videoEl.videoWidth;
                    this.canvas.height = this.videoEl.videoHeight;
                }
                //call the user defined callback
                if (this.callback) {
                    this.callback(this);
                }
            });
        }
    }

    /**
    * success callback when requesting audio input stream
    * @callback audioStreamOpened
    * @param {Object} stream the incoming stream to be used
    */
    audioStreamOpened(stream) {
        this.audioStream = stream;

        debug.log("this.audioStream", this.audioStream);
        //call the user defined callback
        if (this.callback) {
            this.callback(this);
        }
    }

    /**
    * Use in the getUserMedia promise to check errors
    * @param {String} err
    */
    streamError(err) {
        debug.error(err);
    }
}