# hardware/Camera.js

    /** **/
    /**
     * Manager handling the recording of video signal.
     *
     * @author Alain Pitiot
     * @version 2022.2.0
     * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
     * @license Distributed under the terms of the MIT License
     */

    import {Clock} from "../util/Clock.js";
    import {PsychObject} from "../util/PsychObject.js";
    import {PsychoJS} from "../core/PsychoJS.js";
    import * as util from "../util/Util.js";
    import {ExperimentHandler} from "../data/ExperimentHandler.js";
    // import {VideoClip} from "./VideoClip";

    /**
     * <p>This manager handles the recording of video signal.</p>
     *
     * @name module:hardware.Camera
     * @class
     * @param {Object} options
     * @param {module:core.Window} options.win - the associated Window
     * @param {string} [options.format='video/webm;codecs=vp9'] the video format
     * @param {Clock} [options.clock= undefined] - an optional clock
     * @param {boolean} [options.autoLog= false] - whether or not to log
     *
     * @todo add video constraints as parameter
     */
    export class Camera extends PsychObject
    {
    	constructor({win, name, format, clock, autoLog} = {})
    	{
    		super(win._psychoJS);

    		this._addAttribute("win", win, undefined);
    		this._addAttribute("name", name, "camera");
    		this._addAttribute("format", format, "video/webm;codecs=vp9", this._onChange);
    		this._addAttribute("clock", clock, new Clock());
    		this._addAttribute("autoLog", autoLog, false);
    		this._addAttribute("status", PsychoJS.Status.NOT_STARTED);

    		this._stream = null;
    		this._recorder = null;

    		if (this._autoLog)
    		{
    			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
    		}
    	}

    	/**
    	 * Prompt the user for permission to use the camera on their device.
    	 *
    	 * @name module:hardware.Camera#authorize
    	 * @function
    	 * @public
    	 * @param {boolean} [showDialog=false] - whether to open a dialog box to inform the
    	 * 	participant to wait for the camera to be initialised
    	 * @param {string} [dialogMsg] - the dialog message
    	 * @returns {boolean} whether or not the camera is ready to record
    	 */
    	async authorize(showDialog = false, dialogMsg = undefined)
    	{
    		const response = {
    			origin: "Camera.authorize",
    			context: "when authorizing access to the device's camera"
    		};

    		// open pop-up dialog, if required:
    		if (showDialog)
    		{
    			dialogMsg ??= "Please wait a few moments while the camera initialises. You may need to grant permission to your browser to use the camera.";
    			this.psychoJS.gui.dialog({
    				warning: dialogMsg,
    				showOK: false,
    			});
    		}

    		try
    		{
    			// prompt for permission and get a MediaStream:
    			// TODO use size constraints [https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia]
    			this._stream = await navigator.mediaDevices.getUserMedia({
    				video: true
    			});
    		}
    		catch (error)
    		{
    			// close the dialog, if need be:
    			if (showDialog)
    			{
    				this.psychoJS.gui.closeDialog();
    			}

    			this._status = PsychoJS.Status.ERROR;
    			throw {...response, error};
    		}

    		// close the dialog, if need be:
    		if (showDialog)
    		{
    			this.psychoJS.gui.closeDialog();
    		}
    	}

    	/**
    	 * Query whether the camera is ready to record.
    	 *
    	 * @name module:hardware.Camera#isReady
    	 * @function
    	 * @public
    	 * @returns {boolean} true if the camera is ready to record, false otherwise
    	 */
    	get isReady()
    	{
    		return (this._recorder !== null);
    	}

    	/**
    	 * Get the underlying video stream.
    	 *
    	 * @name module:hardware.Camera#getStream
    	 * @function
    	 * @public
    	 * @returns {MediaStream} the video stream
    	 */
    	getStream()
    	{
    		return this._stream;
    	}

    	/**
    	 * Get a video element pointing to the Camera stream.
    	 *
    	 * @name module:hardware.Camera#getVideo
    	 * @function
    	 * @public
    	 * @returns {HTMLVideoElement} a video element
    	 */
    	getVideo()
    	{
    		// note: we need to return a new video each time, since the camera feed can be used by
    		// several stimuli and one of them might pause the feed

    		// create a video with the appropriate size:
    		const video = document.createElement("video");
    		this._videos.push(video);

    		video.width = this._streamSettings.width;
    		video.height = this._streamSettings.height;
    		video.autoplay = true;

    		// prevent clicking:
    		video.onclick = (mouseEvent) =>
    		{
    			mouseEvent.preventDefault();
    			return false;
    		};

    		// use the camera stream as source for the video:
    		video.srcObject = this._stream;

    		return video;
    	}

    	/**
    	 * Open the video stream.
    	 *
    	 * @name module:hardware.Camera#open
    	 * @function
    	 * @public
    	 */
    	open()
    	{
    		if (this._stream === null)
    		{
    			throw {
    				origin: "Camera.open",
    				context: "when opening the camera's video stream",
    				error: "access to the camera has not been authorized, or no camera could be found"
    			};
    		}

    		// prepare the recording:
    		this._prepareRecording();
    	}

    	/**
    	 * Submit a request to start the recording.
    	 *
    	 * @name module:hardware.Camera#record
    	 * @function
    	 * @public
    	 * @return {Promise} promise fulfilled when the recording actually starts
    	 */
    	record()
    	{
    		// if the camera is currently paused, a call to start resumes it
    		// with a new recording:
    		if (this._status === PsychoJS.Status.PAUSED)
    		{
    			return this.resume({clear: true});
    		}

    		if (this._status !== PsychoJS.Status.STARTED)
    		{
    			this._psychoJS.logger.debug("request to start video recording");

    			try
    			{
    				if (!this._recorder)
    				{
    					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
    				}

    				this._recorder.start();

    				// return a promise, which will be satisfied when the recording actually starts, which
    				// is also when the reset of the clock and the change of status takes place
    				const self = this;
    				return new Promise((resolve, reject) =>
    				{
    					self._startCallback = resolve;
    					self._errorCallback = reject;
    				});
    			}
    			catch (error)
    			{
    				this._psychoJS.logger.error("unable to start the video recording: " + JSON.stringify(error));
    				this._status = PsychoJS.Status.ERROR;

    				throw {
    					origin: "Camera.record",
    					context: "when starting the video recording for camera: " + this._name,
    					error
    				};
    			}

    		}

    	}

    	/**
    	 * Submit a request to stop the recording.
    	 *
    	 * @name module:hardware.Camera#stop
    	 * @function
    	 * @public
    	 * @param {Object} options
    	 * @return {Promise} promise fulfilled when the recording actually stopped, and the recorded
    	 * 	data was made available
    	 */
    	stop()
    	{
    		if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
    		{
    			this._psychoJS.logger.debug("request to stop video recording");

    			// stop the videos:
    			for (const video of this._videos)
    			{
    				video.pause();
    			}

    			// note: calling the MediaRecorder.stop will first raise a dataavailable event, and then a stop event
    			// ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop
    			this._recorder.stop();

    			// return a promise, which will be satisfied when the recording actually stops and the data
    			// has been made available:
    			const self = this;
    			return new Promise((resolve, reject) =>
    			{
    				self._stopCallback = resolve;
    				self._errorCallback = reject;
    			});
    		}
    	}

    	/**
    	 * Submit a request to pause the recording.
    	 *
    	 * @name module:hardware.Camera#pause
    	 * @function
    	 * @public
    	 * @return {Promise} promise fulfilled when the recording actually paused
    	 */
    	pause()
    	{
    		if (this._status === PsychoJS.Status.STARTED)
    		{
    			this._psychoJS.logger.debug("request to pause video recording");

    			try
    			{
    				if (!this._recorder)
    				{
    					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
    				}

    				// note: calling the pause method of the MediaRecorder raises a pause event
    				this._recorder.pause();

    				// return a promise, which will be satisfied when the recording actually pauses:
    				const self = this;
    				return new Promise((resolve, reject) =>
    				{
    					self._pauseCallback = resolve;
    					self._errorCallback = reject;
    				});
    			}
    			catch (error)
    			{
    				self._psychoJS.logger.error("unable to pause the video recording: " + JSON.stringify(error));
    				this._status = PsychoJS.Status.ERROR;

    				throw {
    					origin: "Camera.pause",
    					context: "when pausing the video recording for camera: " + this._name,
    					error
    				};
    			}

    		}
    	}

    	/**
    	 * Submit a request to resume the recording.
    	 *
    	 * <p>resume has no effect if the recording was not previously paused.</p>
    	 *
    	 * @name module:hardware.Camera#resume
    	 * @function
    	 * @param {Object} options
    	 * @param {boolean} [options.clear= false] whether or not to empty the video buffer before
    	 * 	resuming the recording
    	 * @return {Promise} promise fulfilled when the recording actually resumed
    	 */
    	resume({clear = false } = {})
    	{
    		if (this._status === PsychoJS.Status.PAUSED)
    		{
    			this._psychoJS.logger.debug("request to resume video recording");

    			try
    			{
    				if (!this._recorder)
    				{
    					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
    				}

    				// empty the audio buffer is needed:
    				if (clear)
    				{
    					this._audioBuffer = [];
    					this._videoBuffer.length = 0;
    				}

    				this._recorder.resume();

    				// return a promise, which will be satisfied when the recording actually resumes:
    				const self = this;
    				return new Promise((resolve, reject) =>
    				{
    					self._resumeCallback = resolve;
    					self._errorCallback = reject;
    				});
    			}
    			catch (error)
    			{
    				self._psychoJS.logger.error("unable to resume the video recording: " + JSON.stringify(error));
    				this._status = PsychoJS.Status.ERROR;

    				throw {
    					origin: "Camera.resume",
    					context: "when resuming the video recording for camera: " + this._name,
    					error
    				};
    			}

    		}
    	}

    	/**
    	 * Submit a request to flush the recording.
    	 *
    	 * @name module:hardware.Camera#flush
    	 * @function
    	 * @public
    	 * @return {Promise} promise fulfilled when the data has actually been made available
    	 */
    	flush()
    	{
    		if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
    		{
    			this._psychoJS.logger.debug("request to flush video recording");

    			// note: calling the requestData method of the MediaRecorder will raise a
    			// dataavailable event
    			// ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/requestData
    			this._recorder.requestData();

    			// return a promise, which will be satisfied when the data has been made available:
    			const self = this;
    			return new Promise((resolve, reject) =>
    			{
    				self._dataAvailableCallback = resolve;
    				self._errorCallback = reject;
    			});
    		}
    	}

    	/**
    	 * Get the current video recording as a VideoClip in the given format.
    	 *
    	 * @name module:hardware.Camera#getRecording
    	 * @function
    	 * @public
    	 * @param {string} tag an optional tag for the video clip
    	 * @param {boolean} [flush=false] whether or not to first flush the recording
    	 */
    	async getRecording({tag, flush = false} = {})
    	{
    		// default tag: the name of this Microphone object
    		if (typeof tag === "undefined")
    		{
    			tag = this._name;
    		}

    		// TODO
    	}

    	/**
    	 * Upload the video recording to the pavlovia server.
    	 *
    	 * @name module:hardware.Camera#_upload
    	 * @function
    	 * @protected
    	 * @param {string} tag an optional tag for the video file
    	 * @param {boolean} [waitForCompletion= false] whether to wait for completion
    	 * 	before returning
    	 * @param {boolean} [showDialog=false] - whether to open a dialog box to inform the participant to wait for the data to be uploaded to the server
    	 * @param {string} [dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
    	 */
    	save({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
    	{
    		this._psychoJS.logger.info("[PsychoJS] Save video recording.");

    		// default tag: the name of this Camera object
    		if (typeof tag === "undefined")
    		{
    			tag = this._name;
    		}

    		// add a format-dependent video extension to the tag:
    		tag += util.extensionFromMimeType(this._format);

    		// if the video recording cannot be uploaded, e.g. the experiment is running locally, or
    		// if it is piloting mode, then we offer the video recording as a file for download:
    		if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
    			this._psychoJS.config.experiment.status !== "RUNNING" ||
    			this._psychoJS._serverMsg.has("__pilotToken"))
    		{
    			const videoBlob = new Blob(this._videoBuffer);

    			const anchor = document.createElement("a");
    			anchor.href = window.URL.createObjectURL(videoBlob);
    			anchor.download = tag;
    			document.body.appendChild(anchor);
    			anchor.click();
    			document.body.removeChild(anchor);

    			return;
    		}

    		// upload the blob:
    		const videoBlob = new Blob(this._videoBuffer);
    		return this._psychoJS.serverManager.uploadAudioVideo({
    			mediaBlob: videoBlob,
    			tag,
    			waitForCompletion,
    			showDialog,
    			dialogMsg});
    	}

    	/**
    	 * Close the camera stream.
    	 *
    	 * @name module:hardware.Camera#close
    	 * @function
    	 * @public
    	 * @returns {Promise<void>} promise fulfilled when the stream has stopped and is now closed
    	 */
    	async close()
    	{
    		await this.stop();

    		this._videos = [];
    		this._stream = null;
    		this._recorder = null;
    	}

    	/**
    	 * Callback for changes to the recording settings.
    	 *
    	 * <p>Changes to the settings require the recording to stop and be re-started.</p>
    	 *
    	 * @name module:hardware.Camera#_onChange
    	 * @function
    	 * @protected
    	 */
    	_onChange()
    	{
    		if (this._status === PsychoJS.Status.STARTED)
    		{
    			this.stop();
    		}

    		this._prepareRecording();

    		this.start();
    	}

    	/**
    	 * Prepare the recording.
    	 *
    	 * @name module:hardware.Camera#_prepareRecording
    	 * @function
    	 * @protected
    	 */
    	_prepareRecording()
    	{
    		// empty the video buffer:
    		this._videoBuffer = [];
    		this._recorder = null;
    		this._videos = [];

    		// check the actual width and height:
    		this._streamSettings = this._stream.getVideoTracks()[0].getSettings();
    		this._psychoJS.logger.debug(`camera stream settings: ${JSON.stringify(this._streamSettings)}`);

    		// check that the specified format is supported, use default if it is not:
    		let options;
    		if (typeof this._format === "string" && MediaRecorder.isTypeSupported(this._format))
    		{
    			options = { type: this._format };
    		}
    		else
    		{
    			this._psychoJS.logger.warn(`The specified video format, ${this._format}, is not supported by this browser, using the default format instead`);
    		}

    		// create a video recorder:
    		this._recorder = new MediaRecorder(this._stream, options);

    		// setup the callbacks:
    		const self = this;

    		// called upon Camera.start(), at which point the audio data starts being gathered
    		// into a blob:
    		this._recorder.onstart = () =>
    		{
    			self._videoBuffer = [];
    			self._videoBuffer.length = 0;
    			self._clock.reset();
    			self._status = PsychoJS.Status.STARTED;
    			self._psychoJS.logger.debug("video recording started");

    			// resolve the Camera.start promise:
    			if (self._startCallback)
    			{
    				self._startCallback(self._psychoJS.monotonicClock.getTime());
    			}
    		};

    		// called upon Camera.pause():
    		this._recorder.onpause = () =>
    		{
    			self._status = PsychoJS.Status.PAUSED;
    			self._psychoJS.logger.debug("video recording paused");

    			// resolve the Camera.pause promise:
    			if (self._pauseCallback)
    			{
    				self._pauseCallback(self._psychoJS.monotonicClock.getTime());
    			}
    		};

    		// called upon Camera.resume():
    		this._recorder.onresume = () =>
    		{
    			self._status = PsychoJS.Status.STARTED;
    			self._psychoJS.logger.debug("video recording resumed");

    			// resolve the Camera.resume promise:
    			if (self._resumeCallback)
    			{
    				self._resumeCallback(self._psychoJS.monotonicClock.getTime());
    			}
    		};

    		// called when video data is available, typically upon Camera.stop() or Camera.flush():
    		this._recorder.ondataavailable = (event) =>
    		{
    			const data = event.data;

    			// add data to the buffer:
    			self._videoBuffer.push(data);
    			self._psychoJS.logger.debug("video data added to the buffer");

    			// resolve the data available promise, if needed:
    			if (self._dataAvailableCallback)
    			{
    				self._dataAvailableCallback(self._psychoJS.monotonicClock.getTime());
    			}
    		};

    		// called upon Camera.stop(), after data has been made available:
    		this._recorder.onstop = () =>
    		{
    			self._psychoJS.logger.debug("video recording stopped");
    			self._status = PsychoJS.Status.STOPPED;

    			// resolve the Camera.stop promise:
    			if (self._stopCallback)
    			{
    				self._stopCallback(self._psychoJS.monotonicClock.getTime());
    			}
    		};

    		// called upon recording errors:
    		this._recorder.onerror = (event) =>
    		{
    			// TODO
    			self._psychoJS.logger.error("video recording error: " + JSON.stringify(event));
    			self._status = PsychoJS.Status.ERROR;
    		};
    	}

    }