text: // Copyright (c) 2013 The Chromium Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  import {browserProxy} from '../browser_proxy/browser_proxy.js';
  import {assert, assertInstanceof} from '../chrome_util.js';
  import {
    PhotoConstraintsPreferrer,  // eslint-disable-line no-unused-vars
    VideoConstraintsPreferrer,  // eslint-disable-line no-unused-vars
  } from '../device/constraints_preferrer.js';
  // eslint-disable-next-line no-unused-vars
  import {DeviceInfoUpdater} from '../device/device_info_updater.js';
  import * as metrics from '../metrics.js';
  // eslint-disable-next-line no-unused-vars
  import {ResultSaver} from '../models/result_saver.js';
  import {ChromeHelper} from '../mojo/chrome_helper.js';
  import {DeviceOperator} from '../mojo/device_operator.js';
  import * as nav from '../nav.js';
  // eslint-disable-next-line no-unused-vars
  import {PerfLogger} from '../perf.js';
  import * as sound from '../sound.js';
  import * as state from '../state.js';
  import * as toast from '../toast.js';
  import {
    Facing,
    Mode,
    ViewName,
  } from '../type.js';
  import * as util from '../util.js';
  
  import {Layout} from './camera/layout.js';
  import {
    Modes,
    PhotoResult,  // eslint-disable-line no-unused-vars
    Video,
    VideoResult,  // eslint-disable-line no-unused-vars
  } from './camera/modes.js';
  import {Options} from './camera/options.js';
  import {Preview} from './camera/preview.js';
  import * as timertick from './camera/timertick.js';
  import {View} from './view.js';
  
  /**
   * Thrown when app window suspended during stream reconfiguration.
   */
  class CameraSuspendedError extends Error {
    /**
     * @param {string=} message Error message.
     */
    constructor(message = 'Camera suspended.') {
      super(message);
      this.name = this.constructor.name;
    }
  }
  
  /**
   * Camera-view controller.
   */
  export class Camera extends View {
    /**
     * @param {!ResultSaver} resultSaver
     * @param {!DeviceInfoUpdater} infoUpdater
     * @param {!PhotoConstraintsPreferrer} photoPreferrer
     * @param {!VideoConstraintsPreferrer} videoPreferrer
     * @param {Mode} defaultMode
     * @param {!PerfLogger} perfLogger
     */
    constructor(
        resultSaver, infoUpdater, photoPreferrer, videoPreferrer, defaultMode,
        perfLogger) {
      super(ViewName.CAMERA);
  
      /**
       * @type {!DeviceInfoUpdater}
       * @private
       */
      this.infoUpdater_ = infoUpdater;
  
      /**
       * @type {!Mode}
       * @protected
       */
      this.defaultMode_ = defaultMode;
  
      /**
       * @type {!PerfLogger}
       * @private
       */
      this.perfLogger_ = perfLogger;
  
      /**
       * Layout handler for the camera view.
       * @type {!Layout}
       * @private
       */
      this.layout_ = new Layout();
  
      /**
       * Video preview for the camera.
       * @type {!Preview}
       * @private
       */
      this.preview_ = new Preview(this.start.bind(this));
  
      /**
       * Options for the camera.
       * @type {!Options}
       * @private
       */
      this.options_ = new Options(infoUpdater, this.start.bind(this));
  
      /**
       * @type {!ResultSaver}
       * @protected
       */
      this.resultSaver_ = resultSaver;
  
      /**
       * Device id of video device of active preview stream. Sets to null when
       * preview become inactive.
       * @type {?string}
       * @private
       */
      this.activeDeviceId_ = null;
  
      const createVideoSaver = async () => resultSaver.startSaveVideo();
  
      const playShutterEffect = () => {
        sound.play('#sound-shutter');
        util.animateOnce(this.preview_.video);
      };
  
      /**
       * Modes for the camera.
       * @type {Modes}
       * @private
       */
      this.modes_ = new Modes(
          this.defaultMode_, photoPreferrer, videoPreferrer,
          this.start.bind(this), this.doSavePhoto_.bind(this), createVideoSaver,
          this.doSaveVideo_.bind(this), playShutterEffect,
          () => this.preview_.toImage());
  
      /**
       * @type {!Facing}
       * @protected
       */
      this.facingMode_ = Facing.UNKNOWN;
  
      /**
       * @type {!metrics.ShutterType}
       * @protected
       */
      this.shutterType_ = metrics.ShutterType.UNKNOWN;
  
      /**
       * @type {boolean}
       * @private
       */
      this.locked_ = false;
  
      /**
       * @type {?number}
       * @private
       */
      this.retryStartTimeout_ = null;
  
      /**
       * Promise for the camera stream configuration process. It's resolved to
       * boolean for whether the configuration is failed and kick out another
       * round of reconfiguration. Sets to null once the configuration is
       * completed.
       * @type {?Promise<boolean>}
       * @private
       */
      this.configuring_ = null;
  
      /**
       * Promise for the current take of photo or recording.
       * @type {?Promise}
       * @protected
       */
      this.take_ = null;
  
      /**
       * Gets type of ways to trigger shutter from click event.
       * @param {!MouseEvent} e
       * @return {!metrics.ShutterType}
       */
      const getShutterType = (e) => {
        if (e.clientX === 0 && e.clientY === 0) {
          return metrics.ShutterType.KEYBOARD;
        }
        return e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents ?
            metrics.ShutterType.TOUCH :
            metrics.ShutterType.MOUSE;
      };
  
      document.querySelector('#start-takephoto')
          .addEventListener('click', (e) => {
            const mouseEvent = assertInstanceof(e, MouseEvent);
            this.beginTake_(getShutterType(mouseEvent));
          });
  
      document.querySelector('#stop-takephoto')
          .addEventListener('click', () => this.endTake_());
  
      const videoShutter = document.querySelector('#recordvideo');
      videoShutter.addEventListener('click', (e) => {
        if (!state.get(state.State.TAKING)) {
          this.beginTake_(getShutterType(assertInstanceof(e, MouseEvent)));
        } else {
          this.endTake_();
        }
      });
  
      document.querySelector('#video-snapshot').addEventListener('click', () => {
        const videoMode = assertInstanceof(this.modes_.current, Video);
        videoMode.takeSnapshot();
      });
  
      const pauseShutter = document.querySelector('#pause-recordvideo');
      pauseShutter.addEventListener('click', () => {
        const videoMode = assertInstanceof(this.modes_.current, Video);
        videoMode.togglePaused();
      });
  
      // TODO(shik): Tune the timing for playing video shutter button animation.
      // Currently the |TAKING| state is ended when the file is saved.
      util.bindElementAriaLabelWithState({
        element: videoShutter,
        state: state.State.TAKING,
        onLabel: 'record_video_stop_button',
        offLabel: 'record_video_start_button'
      });
      util.bindElementAriaLabelWithState({
        element: pauseShutter,
        state: state.State.RECORDING_PAUSED,
        onLabel: 'record_video_resume_button',
        offLabel: 'record_video_pause_button'
      });
  
      // Monitor the states to stop camera when locked/minimized.
      ChromeHelper.getInstance().addOnLockListener((isLocked) => {
        this.locked_ = isLocked;
        if (this.locked_) {
          this.start();
        }
      });
      chrome.app.window.current().onMinimized.addListener(() => this.start());
  
      document.addEventListener('visibilitychange', () => {
        const recording = state.get(state.State.TAKING) && state.get(Mode.VIDEO);
        if (this.isTabletBackground_() && !recording) {
          this.start();
        }
      });
  
      state.addObserver(state.State.SCREEN_OFF_AUTO, () => this.start());
  
      this.configuring_ = null;
    }
  
    /**
     * Initializes camera view.
     * @return {!Promise}
     */
    async initialize() {
      const helper = await ChromeHelper.getInstance();
  
      const setTablet = (isTablet) => state.set(state.State.TABLET, isTablet);
      const isTablet = await helper.initTabletModeMonitor(setTablet);
      setTablet(isTablet);
  
      const setScreenOffAuto = (s) => {
        const offAuto = s === chromeosCamera.mojom.ScreenState.OFF_AUTO;
        state.set(state.State.SCREEN_OFF_AUTO, offAuto);
      };
      const screenState = await helper.initScreenStateMonitor(setScreenOffAuto);
      setScreenOffAuto(screenState);
    }
  
    /**
     * @return {boolean} Returns if window is fully overlapped by other window in
     * both window mode or tablet mode.
     * @private
     */
    get isVisible_() {
      return document.visibilityState !== 'hidden';
    }
  
    /**
     * @return {boolean} Whether window is put to background in tablet mode.
     * @private
     */
    isTabletBackground_() {
      return state.get(state.State.TABLET) && !this.isVisible_;
    }
  
    /**
     * Whether app window is suspended.
     * @return {boolean}
     */
    isSuspended() {
      return this.locked_ || chrome.app.window.current().isMinimized() ||
          state.get(state.State.SUSPEND) ||
          state.get(state.State.SCREEN_OFF_AUTO) || this.isTabletBackground_();
    }
  
    /**
     * @override
     */
    focus() {
      // Avoid focusing invisible shutters.
      document.querySelectorAll('.shutter')
          .forEach((btn) => btn.offsetParent && btn.focus());
    }
  
    /**
     * Begins to take photo or recording with the current options, e.g. timer.
     * @param {metrics.ShutterType} shutterType The shutter is triggered by which
     *     shutter type.
     * @return {?Promise} Promise resolved when take action completes. Returns
     *     null if CCA can't start take action.
     * @protected
     */
    beginTake_(shutterType) {
      if (!state.get(state.State.STREAMING) || state.get(state.State.TAKING)) {
        return null;
      }
  
      state.set(state.State.TAKING, true);
      this.shutterType_ = shutterType;
      this.focus();  // Refocus the visible shutter button for ChromeVox.
      this.take_ = (async () => {
        let hasError = false;
        try {
          await timertick.start();
          await this.modes_.current.startCapture();
        } catch (e) {
          hasError = true;
          if (e && e.message === 'cancel') {
            return;
          }
          console.error(e);
        } finally {
          this.take_ = null;
          state.set(
              state.State.TAKING, false, {hasError, facing: this.facingMode_});
          this.focus();  // Refocus the visible shutter button for ChromeVox.
        }
      })();
      return this.take_;
    }
  
    /**
     * Ends the current take (or clears scheduled further takes if any.)
     * @return {!Promise} Promise for the operation.
     * @private
     */
    endTake_() {
      timertick.cancel();
      this.modes_.current.stopCapture();
      return Promise.resolve(this.take_);
    }
  
    /**
     * Handles captured photo result.
     * @param {!PhotoResult} result Captured photo result.
     * @param {string} name Name of the photo result to be saved as.
     * @return {!Promise} Promise for the operation.
     * @protected
     */
    async doSavePhoto_({resolution, blob, isVideoSnapshot = false}, name) {
      metrics.log(metrics.Type.CAPTURE, {
        facing: this.facingMode_,
        resolution,
        shutterType: this.shutterType_,
        isVideoSnapshot,
      });
      try {
        await this.resultSaver_.savePhoto(blob, name);
      } catch (e) {
        toast.show('error_msg_save_file_failed');
        throw e;
      }
    }
  
    /**
     * Handles captured video result.
     * @param {!VideoResult} result Captured video result.
     * @return {!Promise} Promise for the operation.
     * @protected
     */
    async doSaveVideo_({resolution, duration, videoSaver, everPaused}) {
      metrics.log(metrics.Type.CAPTURE, {
        facing: this.facingMode_,
        duration,
        resolution,
        shutterType: this.shutterType_,
        everPaused,
      });
      try {
        await this.resultSaver_.finishSaveVideo(videoSaver);
      } catch (e) {
        toast.show('error_msg_save_file_failed');
        throw e;
      }
    }
  
    /**
     * @override
     */
    layout() {
      this.layout_.update();
    }
  
    /**
     * @override
     */
    handlingKey(key) {
      if (key === 'Ctrl-R') {
        toast.show(this.preview_.toString());
        return true;
      }
      if ((key === 'AudioVolumeUp' || key === 'AudioVolumeDown') &&
          state.get(state.State.TABLET) && state.get(state.State.STREAMING)) {
        if (state.get(state.State.TAKING)) {
          this.endTake_();
        } else {
          this.beginTake_(metrics.ShutterType.VOLUME_KEY);
        }
        return true;
      }
      return false;
    }
  
    /**
     * Stops camera and tries to start camera stream again if possible.
     * @return {!Promise<boolean>} Promise resolved to whether start camera
     *     successfully.
     */
    async start() {
      // To prevent multiple callers enter this function at the same time, wait
      // until previous caller resets configuring to null.
      while (this.configuring_ !== null) {
        if (!await this.configuring_) {
          // Retry will be kicked out soon.
          return false;
        }
      }
      state.set(state.State.CAMERA_CONFIGURING, true);
      this.configuring_ = (async () => {
        try {
          if (state.get(state.State.TAKING)) {
            await this.endTake_();
          }
        } finally {
          this.preview_.stop();
        }
        return this.start_();
      })();
      return this.configuring_;
    }
  
    /**
     * Try start stream reconfiguration with specified mode and device id.
     * @param {?string} deviceId
     * @param {!Mode} mode
     * @return {!Promise<boolean>} If found suitable stream and reconfigure
     *     successfully.
     */
    async startWithMode_(deviceId, mode) {
      const deviceOperator = await DeviceOperator.getInstance();
      let resolCandidates = null;
      if (deviceOperator !== null) {
        if (deviceId !== null) {
          const previewRs =
              (await this.infoUpdater_.getDeviceResolutions(deviceId)).video;
          resolCandidates =
              this.modes_.getResolutionCandidates(mode, deviceId, previewRs);
        } else {
          console.error(
              'Null device id present on HALv3 device. Fallback to v1.');
        }
      }
      if (resolCandidates === null) {
        resolCandidates = this.modes_.getResolutionCandidatesV1(mode, deviceId);
      }
      for (const {resolution: captureR, previewCandidates} of resolCandidates) {
        for (const constraints of previewCandidates) {
          if (this.isSuspended()) {
            throw new CameraSuspendedError();
          }
          try {
            if (deviceOperator !== null) {
              assert(deviceId !== null);
              const optConfigs =
                  mode === Mode.VIDEO ? {} : {stillCaptureResolution: captureR};
              await deviceOperator.setStreamConfig(
                  deviceId, constraints, optConfigs);
              await deviceOperator.setCaptureIntent(
                  deviceId, this.modes_.getCaptureIntent(mode));
            }
            const stream = await navigator.mediaDevices.getUserMedia(constraints);
            await this.preview_.start(stream);
            this.facingMode_ = await this.options_.updateValues(stream);
            await this.modes_.updateModeSelectionUI(deviceId);
            await this.modes_.updateMode(
                mode, stream, this.facingMode_, deviceId, captureR);
            nav.close(ViewName.WARNING, 'no-camera');
            return true;
          } catch (e) {
            this.preview_.stop();
            console.error(e);
          }
        }
      }
      return false;
    }
  
    /**
     * Try start stream reconfiguration with specified device id.
     * @param {?string} deviceId
     * @return {!Promise<boolean>} If found suitable stream and reconfigure
     *     successfully.
     */
    async startWithDevice_(deviceId) {
      const supportedModes = await this.modes_.getSupportedModes(deviceId);
      const modes = this.modes_.getModeCandidates().filter(
          (m) => supportedModes.includes(m));
      for (const mode of modes) {
        if (await this.startWithMode_(deviceId, mode)) {
          return true;
        }
      }
      return false;
    }
  
    /**
     * Starts camera configuration process.
     * @return {!Promise<boolean>} Resolved to boolean for whether the
     *     configuration is succeeded or kicks out another round of
     *     reconfiguration.
     * @private
     */
    async start_() {
      try {
        await this.infoUpdater_.lockDeviceInfo(async () => {
          if (!this.isSuspended()) {
            for (const id of await this.options_.videoDeviceIds()) {
              if (await this.startWithDevice_(id)) {
                // Make the different active camera announced by screen reader.
                const currentId = this.options_.currentDeviceId;
                assert(currentId !== null);
                if (currentId === this.activeDeviceId_) {
                  return;
                }
                this.activeDeviceId_ = currentId;
                const info = await this.infoUpdater_.getDeviceInfo(currentId);
                if (info !== null) {
                  toast.speak(browserProxy.getI18nMessage(
                      'status_msg_camera_switched', info.label));
                }
                return;
              }
            }
          }
          throw new CameraSuspendedError();
        });
        this.configuring_ = null;
        state.set(state.State.CAMERA_CONFIGURING, false);
  
        return true;
      } catch (error) {
        this.activeDeviceId_ = null;
        if (!(error instanceof CameraSuspendedError)) {
          console.error(error);
          nav.open(ViewName.WARNING, 'no-camera');
        }
        // Schedule to retry.
        if (this.retryStartTimeout_) {
          clearTimeout(this.retryStartTimeout_);
          this.retryStartTimeout_ = null;
        }
        this.retryStartTimeout_ = setTimeout(() => {
          this.configuring_ = this.start_();
        }, 100);
  
        this.perfLogger_.interrupt();
        return false;
      }
    }
  }
  