chromium/ash/webui/camera_app_ui/resources/js/device/capture_candidate_preferrer.ts

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertNotReached} from '../assert.js';
import * as expert from '../expert.js';
import {getBoard} from '../models/load_time_data.js';
import * as localStorage from '../models/local_storage.js';
import {
  AspectRatioSet,
  LocalStorageKey,
  Mode,
  PhotoResolutionLevel,
  Resolution,
  VideoResolutionLevel,
} from '../type.js';
import {toAspectRatioSet} from '../util.js';

import {
  Camera3DeviceInfo,
  CapturePreviewPairs,
} from './camera3_device_info.js';
import {
  CaptureCandidate,
  PhotoCaptureCandidate,
  VideoCaptureCandidate,
} from './capture_candidate.js';
import {
  CameraConfig,
  PhotoAspectRatioOptionListener,
  PhotoResolutionOption,
  PhotoResolutionOptionGroup,
  PhotoResolutionOptionListener,
  SUPPORTED_CONSTANT_FPS,
  VideoFpsOption,
  VideoResolutionOption,
  VideoResolutionOptionListener,
} from './type.js';

interface VideoLevelResolution {
  level: VideoResolutionLevel;
  resolutions: Resolution[];
}

export class CaptureCandidatePreferrer {
  /**
   * Map of camera infos with the device id as keys.
   */
  private readonly cameraInfos = new Map<string, Camera3DeviceInfo>();

  /**
   * Current camera config.
   */
  private cameraConfig: CameraConfig|null = null;

  /**
   * Map of all available photo resolutions grouped by the device id which are
   * used for aspect ratio which needs cropping.
   */
  private readonly photoOptionsForCrop =
      new Map<string, PhotoResolutionOption[]>();

  /**
   * Map of the current available photo resolutions grouped by the device id and
   * the aspect ratio set.
   */
  private readonly photoOptions =
      new Map<string, Map<AspectRatioSet, PhotoResolutionOption[]>>();

  /**
   * Map of the current available video resolutions grouped by the device id.
   */
  private readonly videoOptions = new Map<string, VideoResolutionOption[]>();

  /**
   * Object saving fps preference that each of its key as device id and value
   * as an object mapping from resolution to preferred constant fps for that
   * resolution.
   */
  private prefVideoFpsesMap:
      Record<string, Record<VideoResolutionLevel, number>> =
          localStorage.getObject(
              LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_FPS);

  /**
   * Map saving preference that each of its key as device id and value to be
   * preferred photo resolution level.
   */
  private prefPhotoResolutionLevelMap: Record<string, PhotoResolutionLevel> =
      localStorage.getObject(
          LocalStorageKey.PREF_DEVICE_PHOTO_RESOLUTION_LEVEL);

  /**
   * Map saving preference that each of its key as device id and
   * value to be preferred photo aspect ratio set.
   */
  private prefPhotoAspectRatioSetMap: Record<string, AspectRatioSet> =
      localStorage.getObject(
          LocalStorageKey.PREF_DEVICE_PHOTO_ASPECT_RATIO_SET);

  /**
   * Map saving prioritized photo aspect ratio order. Keys are device IDs and
   * values are the corresponding arrays of aspect ratio sets.
   */
  private prioritizedPhotoAspectRatioOrderMap:
      Record<string, AspectRatioSet[]> = {};

  /**
   * Map saving preference that each of its key as device id and value to be
   * preferred video resolution level.
   */
  private prefVideoResolutionLevelMap: Record<string, VideoResolutionLevel> =
      localStorage.getObject(
          LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_LEVEL);

  /**
   * Map saving preference that each of its key as device id and value to be
   * preferred photo resolution. It is used when showing all resolutions is on.
   */
  private prefPhotoResolutionMap:
      Record<string, Record<AspectRatioSet, Resolution>> =
          localStorage.getObject(
              LocalStorageKey.PREF_DEVICE_PHOTO_RESOLUTION_EXPERT);

  /**
   * Map saving preference that each of its key as device id and value to be
   * preferred video resolution. It is used when showing all resolutions is on.
   */
  private prefVideoResolutionMap: Record<string, Resolution> =
      localStorage.getObject(
          LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_EXPERT);

  private readonly photoResolutionOptionListeners:
      PhotoResolutionOptionListener[] = [];

  private readonly photoAspectRatioOptionListeners:
      PhotoAspectRatioOptionListener[] = [];

  private readonly videoResolutionOptionListeners:
      VideoResolutionOptionListener[] = [];

  /**
   * Adds `listener` for photo resolution options.
   */
  addPhotoResolutionOptionListener(listener: PhotoResolutionOptionListener):
      void {
    this.photoResolutionOptionListeners.push(listener);
  }

  /**
   * Adds `listener` for photo aspect ratio options.
   */
  addPhotoAspectRatioOptionListener(listener: PhotoAspectRatioOptionListener):
      void {
    this.photoAspectRatioOptionListeners.push(listener);
  }

  /**
   * Adds `listener` for video resolution options.
   */
  addVideoResolutionOptionListener(listener: VideoResolutionOptionListener):
      void {
    this.videoResolutionOptionListeners.push(listener);
  }

  /**
   * Updates the camera capabilities.
   */
  updateCapability(infos: Camera3DeviceInfo[]): void {
    this.cameraInfos.clear();
    for (const info of infos) {
      this.cameraInfos.set(info.deviceId, info);
    }
    this.buildOptions();
    this.notifyListeners();
  }

  /**
   * Called when the current camera config is updated.
   */
  onUpdateConfig(config: CameraConfig): void {
    this.cameraConfig = config;
    this.notifyListeners();
  }

  /**
   * Gets all the capture candidates sorted based on users preferences.
   */
  getSortedCandidates(
      infos: Camera3DeviceInfo[], deviceId: string, mode: Mode,
      hasAudio: boolean): CaptureCandidate[] {
    if (this.cameraInfos === null) {
      this.updateCapability(infos);
    }
    if (mode === Mode.VIDEO) {
      return this.getVideoCandidates(deviceId, hasAudio);
    } else {
      const candidates = this.getPhotoCandidates(deviceId);
      if (mode === Mode.SCAN) {
        candidates.sort(
            (c1, c2) => (c2.resolution?.mp ?? 0) - (c1.resolution?.mp ?? 0));
      }
      return candidates;
    }
  }

  /**
   * Sets photo `resolutionLevel` preference.
   */
  setPrefPhotoResolutionLevel(
      deviceId: string, resolutionLevel: PhotoResolutionLevel): void {
    this.prefPhotoResolutionLevelMap[deviceId] = resolutionLevel;
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_PHOTO_RESOLUTION_LEVEL,
        this.prefPhotoResolutionLevelMap);

    // For opening camera, it will be notified after the reconfiguration.
    if (deviceId !== this.cameraConfig?.deviceId) {
      this.notifyListeners();
    }
  }

  /**
   * Sets photo `aspectRatioSet` preference.
   */
  setPrefPhotoAspectRatioSet(deviceId: string, aspectRatioSet: AspectRatioSet):
      void {
    this.prefPhotoAspectRatioSetMap[deviceId] = aspectRatioSet;
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_PHOTO_ASPECT_RATIO_SET,
        this.prefPhotoAspectRatioSetMap);

    // For opening camera, it will be notified after the reconfiguration.
    if (deviceId !== this.cameraConfig?.deviceId) {
      this.notifyListeners();
    }
  }

  /**
   * Sets video `resolutionLevel` preference.
   */
  setPrefVideoResolutionLevel(
      deviceId: string, resolutionLevel: VideoResolutionLevel): void {
    this.prefVideoResolutionLevelMap[deviceId] = resolutionLevel;
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_LEVEL,
        this.prefVideoResolutionLevelMap);

    // For opening camera, it will be notified after the reconfiguration.
    if (deviceId !== this.cameraConfig?.deviceId) {
      this.notifyListeners();
    }
  }

  /**
   * Sets video constant frame rate preference.
   */
  setPrefVideoConstFps(
      deviceId: string, resolutionLevel: VideoResolutionLevel, prefFps: number,
      shouldReconfigure: boolean): void {
    this.prefVideoFpsesMap[deviceId] =
        {...this.prefVideoFpsesMap[deviceId], [resolutionLevel]: prefFps};
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_FPS,
        this.prefVideoFpsesMap);

    // For opening camera, it will be notified after the reconfiguration.
    if (!shouldReconfigure) {
      this.notifyListeners();
    }
  }

  /**
   * Used when showing all resolutions.
   */
  setPrefPhotoResolution(deviceId: string, resolution: Resolution): void {
    const aspectRatioSet = this.preferSquarePhoto(deviceId) ?
        AspectRatioSet.RATIO_SQUARE :
        toAspectRatioSet(resolution);
    this.setPreferPhotoResolution(deviceId, aspectRatioSet, resolution);
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_PHOTO_RESOLUTION_EXPERT,
        this.prefPhotoResolutionMap);

    // For opening camera, it will be notified after the reconfiguration.
    if (deviceId !== this.cameraConfig?.deviceId) {
      this.notifyListeners();
    }
  }

  /**
   * Used when showing all resolutions.
   */
  setPrefVideoResolution(deviceId: string, resolution: Resolution): void {
    this.prefVideoResolutionMap[deviceId] = resolution;
    localStorage.set(
        LocalStorageKey.PREF_DEVICE_VIDEO_RESOLUTION_EXPERT,
        this.prefVideoResolutionMap);

    // For opening camera, it will be notified after the reconfigure.
    if (deviceId !== this.cameraConfig?.deviceId) {
      this.notifyListeners();
    }
  }

  /**
   * Builds the photo and video options according to the camera info.
   */
  buildOptions(): void {
    function extractCaptureResolutions(pairs: CapturePreviewPairs) {
      const resolutions = [];
      for (const pair of pairs) {
        resolutions.push(...pair.captureResolutions);
      }
      return resolutions;
    }

    if (this.cameraInfos === null) {
      return;
    }
    this.photoOptions.clear();
    this.photoOptionsForCrop.clear();
    this.videoOptions.clear();
    for (const [deviceId, info] of this.cameraInfos.entries()) {
      this.buildPhotoOptions(
          deviceId, extractCaptureResolutions(info.photoPreviewPairs));
      this.buildPhotoOptionsForCrop(
          deviceId, extractCaptureResolutions(info.photoPreviewPairs));
      this.buildVideoOptions(
          deviceId, extractCaptureResolutions(info.videoPreviewPairs),
          (r) => info.getConstFpses(r));
    }
  }

  /**
   * Returns whether it currently prefers square photo.
   */
  preferSquarePhoto(deviceId: string): boolean {
    return this.prefPhotoAspectRatioSetMap[deviceId] ===
        AspectRatioSet.RATIO_SQUARE;
  }

  /**
   * Returns the photo resolution level where the `resolution` belongs in the
   * current opened camera.
   */
  getPhotoResolutionLevel(resolution: Resolution): PhotoResolutionLevel {
    if (this.photoOptions.size === 0) {
      // Only fake camera will reach here.
      return PhotoResolutionLevel.UNKNOWN;
    }

    assert(this.cameraConfig !== null);
    const optionsGroups =
        this.photoOptions.get(this.cameraConfig.deviceId)?.values();
    assert(optionsGroups !== undefined);
    for (const options of optionsGroups) {
      for (const {resolutionLevel, resolutions} of options) {
        if (resolutions.some((r) => resolution.equalsWithRotation(r))) {
          return resolutionLevel;
        }
      }
    }
    assertNotReached();
  }

  /**
   * Returns the video resolution level where the `resolution` belongs in the
   * current opened camera.
   */
  getVideoResolutionLevel(resolution: Resolution): VideoResolutionLevel {
    if (this.videoOptions.size === 0) {
      // Only fake camera will reach here.
      return VideoResolutionLevel.UNKNOWN;
    }

    assert(this.cameraConfig !== null);
    const options = this.videoOptions.get(this.cameraConfig.deviceId);
    assert(options !== undefined);
    for (const {resolutionLevel, fpsOptions} of options) {
      for (const {resolutions} of fpsOptions) {
        if (resolutions.some((r) => resolution.equalsWithRotation(r))) {
          return resolutionLevel;
        }
      }
    }
    assertNotReached();
  }

  private getPhotoCandidates(deviceId: string): CaptureCandidate[] {
    const cameraInfo = this.cameraInfos.get(deviceId);
    assert(cameraInfo !== undefined);

    const candidates = [];

    const prefLevel = this.prefPhotoResolutionLevelMap[deviceId];
    const showAllResolutions =
        expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS);
    const prefAspectRatioSet = this.prefPhotoAspectRatioSetMap[deviceId];
    const aspectRatioOptions = this.photoOptions.get(deviceId);
    assert(aspectRatioOptions !== undefined);
    for (const [aspectRatioSet, options] of aspectRatioOptions.entries()) {
      const prefResolution =
          this.getPreferPhotoResolution(deviceId, aspectRatioSet);
      const candidatesByAspectRatio = [];
      const photoPreviewPair = cameraInfo.photoPreviewPairs.find(
          (pair) => pair.captureResolutions[0].aspectRatioEquals(
              options[0].resolutions[0]));
      assert(photoPreviewPair !== undefined);
      for (const option of options) {
        const candidatesByLevel = option.resolutions.map(
            (r) => new PhotoCaptureCandidate(
                deviceId, r, photoPreviewPair.previewResolutions,
                cameraInfo.builtinPTZSupport));
        if (showAllResolutions &&
            option.resolutions[0].equals(prefResolution)) {
          candidatesByAspectRatio.unshift(...candidatesByLevel);
        } else if (
            !showAllResolutions && option.resolutionLevel === prefLevel) {
          candidatesByAspectRatio.unshift(...candidatesByLevel);
        } else {
          candidatesByAspectRatio.push(...candidatesByLevel);
        }
      }
      if (aspectRatioSet === prefAspectRatioSet) {
        candidates.unshift(...candidatesByAspectRatio);
      } else {
        candidates.push(...candidatesByAspectRatio);
      }
    }
    return candidates;
  }

  private getVideoCandidates(deviceId: string, hasAudio: boolean):
      CaptureCandidate[] {
    const cameraInfo = this.cameraInfos.get(deviceId);
    assert(cameraInfo !== undefined);
    const candidates = [];
    const prefLevel = this.prefVideoResolutionLevelMap[deviceId];
    const prefResolution = this.prefVideoResolutionMap[deviceId] ?? null;
    const options = this.videoOptions.get(deviceId);
    const showAllResolutions =
        expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS);
    assert(options !== undefined);
    for (const option of options) {
      const prefFps = this.getFallbackFPS(deviceId, option.resolutionLevel);
      const targetFpsCandidates = [];
      const otherFpsCandidates = [];
      const videoPreviewPair = cameraInfo.videoPreviewPairs.find(
          (pair) => pair.captureResolutions[0].aspectRatioEquals(
              option.fpsOptions[0].resolutions[0]));
      assert(videoPreviewPair !== undefined);
      const previewResolutions = videoPreviewPair.previewResolutions;
      for (const {constFps, resolutions} of option.fpsOptions) {
        for (const resolution of resolutions) {
          const candidate = new VideoCaptureCandidate(
              deviceId, resolution, previewResolutions, constFps, hasAudio);
          if (prefFps === constFps) {
            targetFpsCandidates.push(candidate);
          } else {
            otherFpsCandidates.push(candidate);
          }
        }
      }
      if (showAllResolutions &&
          option.fpsOptions.some(
              (fpsOption) => fpsOption.resolutions[0].equals(prefResolution))) {
        candidates.unshift(...otherFpsCandidates);
        candidates.unshift(...targetFpsCandidates);
      } else if (!showAllResolutions && option.resolutionLevel === prefLevel) {
        candidates.unshift(...otherFpsCandidates);
        candidates.unshift(...targetFpsCandidates);
      } else {
        candidates.push(...targetFpsCandidates);
        candidates.push(...otherFpsCandidates);
      }
    }
    return candidates;
  }

  /**
   * Splits the given `resolutions` to up to 2 groups by the 60% of the maximum
   * resolution and converts them to photo resolution options.
   */
  private createPhotoResolutionOptions(resolutions: Resolution[]):
      PhotoResolutionOption[] {
    if (resolutions.length === 0) {
      return [];
    }

    resolutions.sort((r1, r2) => r2.area - r1.area);
    const threshold = resolutions[0].area * 0.6;
    const splitIndex = resolutions.findIndex((r) => r.area < threshold);
    const options = [];
    if (splitIndex === -1) {
      options.push({
        resolutionLevel: PhotoResolutionLevel.FULL,
        resolutions,
        checked: false,
      });
    } else {
      options.push(
          {
            resolutionLevel: PhotoResolutionLevel.FULL,
            resolutions: resolutions.slice(0, splitIndex),
            checked: false,
          },
          {
            resolutionLevel: PhotoResolutionLevel.MEDIUM,
            resolutions: resolutions.slice(splitIndex),
            checked: false,
          },
      );
    }
    if (expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
      return options.flatMap(
          (option) =>
              option.resolutions.map((r) => ({
                                       resolutionLevel: option.resolutionLevel,
                                       resolutions: [r],
                                       checked: false,
                                     })));
    }
    return options;
  }

  private buildPhotoOptions(deviceId: string, resolutions: Resolution[]): void {
    const aspectRatioSetPreferOrder = getAspectRatioSetPreferOrder();

    // Making sure that the prefer aspect ratio has resolution which is equal to
    // or larger than 720p.
    const prioritizedAspectRatioSet =
        aspectRatioSetPreferOrder.find(
            (ratio) => resolutions.some(
                (r) => toAspectRatioSet(r) === ratio && r.height >= 720)) ??
        aspectRatioSetPreferOrder[0];
    const prioritizedAspectRatioOrder = [
      prioritizedAspectRatioSet,
      ...aspectRatioSetPreferOrder.filter(
          (ratio) => ratio !== prioritizedAspectRatioSet),
    ];
    this.prioritizedPhotoAspectRatioOrderMap[deviceId] =
        prioritizedAspectRatioOrder;

    /**
     * Categorizes the photo resolutions according to their aspect ratio and
     * sorts them.
     */
    function groupResolutions(
        resolutions: Resolution[], preferAspectRatioSetOrder: AspectRatioSet[]):
        Map<AspectRatioSet, Resolution[]> {
      const resolutionGroups = new Map<AspectRatioSet, Resolution[]>();
      for (const aspectRatioSet of preferAspectRatioSetOrder) {
        resolutionGroups.set(aspectRatioSet, []);
      }

      for (const resolution of resolutions) {
        const aspectRatioSet = toAspectRatioSet(resolution);
        resolutionGroups.get(aspectRatioSet)?.push(resolution);
      }
      return resolutionGroups;
    }

    const resolutionGroups =
        groupResolutions(resolutions, prioritizedAspectRatioOrder);
    const options = new Map<AspectRatioSet, PhotoResolutionOption[]>();
    for (const aspectRatioSet of prioritizedAspectRatioOrder) {
      const resolutionGroup = resolutionGroups.get(aspectRatioSet);
      assert(resolutionGroup !== undefined);
      if (resolutionGroup.length > 0) {
        options.set(
            aspectRatioSet, this.createPhotoResolutionOptions(resolutionGroup));
      }
      if (this.getPreferPhotoResolution(deviceId, aspectRatioSet) === null) {
        const maxResolution = resolutionGroup.reduce(
            (max, r) => r.mp > max.mp ? r : max, new Resolution());
        this.setPreferPhotoResolution(deviceId, aspectRatioSet, maxResolution);
      }
    }
    this.photoOptions.set(deviceId, options);
  }

  private buildPhotoOptionsForCrop(deviceId: string, resolutions: Resolution[]):
      void {
    if (this.getPreferPhotoResolution(deviceId, AspectRatioSet.RATIO_SQUARE) ===
        null) {
      const maxResolution = resolutions.reduce(
          (max, r) => r.mp > max.mp ? r : max, new Resolution());
      this.setPreferPhotoResolution(
          deviceId, AspectRatioSet.RATIO_SQUARE, maxResolution);
    }
    this.photoOptionsForCrop.set(
        deviceId, this.createPhotoResolutionOptions(resolutions));
  }

  private buildVideoOptions(
      deviceId: string, resolutions: Resolution[],
      getConstFpses: (resolution: Resolution) => number[]): void {
    function toVideoOptions(levelResolutions: VideoLevelResolution[]) {
      const options: VideoResolutionOption[] = [];

      for (const entry of levelResolutions) {
        const fpsMap = new Map<number|null, Resolution[]>();
        function putResolution(fps: number|null, resolution: Resolution) {
          const list = fpsMap.get(fps);
          if (list === undefined) {
            fpsMap.set(fps, [resolution]);
          } else {
            list.push(resolution);
          }
        }
        for (const resolution of entry.resolutions) {
          for (const fps of getConstFpses(resolution)
                   .filter((fps) => SUPPORTED_CONSTANT_FPS.includes(fps))) {
            putResolution(fps, resolution);
          }
          // Every resolution is a candidate of non-constant fps.
          putResolution(null, resolution);
        }
        const fpsOptions: VideoFpsOption[] = [];
        for (const [constFps, resolutions] of fpsMap.entries()) {
          fpsOptions.push({
            constFps,
            resolutions,
            checked: false,
          });
        }
        options.push({
          resolutionLevel: entry.level,
          fpsOptions,
          checked: false,
        });
      }
      return options;
    }

    const COMMON_VIDEO_OPTIONS = [
      {
        level: VideoResolutionLevel.FOUR_K,
        resolution: new Resolution(3840, 2160),
      },
      {
        level: VideoResolutionLevel.QUAD_HD,
        resolution: new Resolution(2560, 1440),
      },
      {
        level: VideoResolutionLevel.FULL_HD,
        resolution: new Resolution(1920, 1080),
      },
      {
        level: VideoResolutionLevel.HD,
        resolution: new Resolution(1280, 720),
      },
      {
        level: VideoResolutionLevel.THREE_SIXTY_P,
        resolution: new Resolution(640, 360),
      },
    ];
    resolutions.sort((r1, r2) => r2.area - r1.area);

    let matches: VideoLevelResolution[] = [];
    if (!expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
      for (const resolution of resolutions) {
        const option = COMMON_VIDEO_OPTIONS.find(
            (option) => option.resolution.equals(resolution));
        if (option === undefined) {
          continue;
        }
        matches.push({
          level: option.level,
          resolutions: [option.resolution],
        });
      }
    }
    if (matches.length === 0) {
      const threshold = resolutions[0].area * 0.6;
      const splitIndex = resolutions.findIndex((r) => r.area < threshold);
      if (splitIndex === -1) {
        matches.push({
          level: VideoResolutionLevel.FULL,
          resolutions,
        });
      } else {
        matches.push({
          level: VideoResolutionLevel.FULL,
          resolutions: resolutions.slice(0, splitIndex),
        });
        matches.push({
          level: VideoResolutionLevel.MEDIUM,
          resolutions: resolutions.slice(splitIndex),
        });
      }
    }

    if (expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
      matches =
          matches.flatMap((match) => match.resolutions.map((r) => ({
                                                             level: match.level,
                                                             resolutions: [r],
                                                           })));
    }
    this.videoOptions.set(deviceId, toVideoOptions(matches));

    if (this.prefVideoResolutionMap[deviceId] === undefined) {
      const maxResolution = resolutions.reduce(
          (max, r) => r.mp > max.mp ? r : max, new Resolution());
      this.prefVideoResolutionMap[deviceId] = maxResolution;
    }
  }

  private getChosenAspectRatio(
      deviceId: string,
      aspectRatioOptionsMap: Map<AspectRatioSet, PhotoResolutionOption[]>):
      AspectRatioSet {
    // For opening camera, select the corresponding aspect ratio for current
    // resolution if the user preference is not square. Otherwise, select
    // according to the use user preference.
    const prefAspectRatioSet = this.prefPhotoAspectRatioSetMap[deviceId];
    if (deviceId === this.cameraConfig?.deviceId &&
        this.cameraConfig?.mode !== Mode.VIDEO &&
        prefAspectRatioSet !== AspectRatioSet.RATIO_SQUARE) {
      return toAspectRatioSet(this.cameraConfig.captureCandidate.resolution);
    } else {
      return prefAspectRatioSet ??
          getFallbackAspectRatioSet(
                 aspectRatioOptionsMap,
                 this.prioritizedPhotoAspectRatioOrderMap[deviceId]);
    }
  }

  /**
   * Returns the photo resolution level preference of the given device.
   *
   * Fallback to the first resolution level if the preferred resolution level
   * doesn't exist in the option set.
   */
  private getPreferredPhotoResolutionLevel(
      deviceId: string,
      photoResoltionOptions: PhotoResolutionOption[]): PhotoResolutionLevel {
    assert(photoResoltionOptions.length > 0);
    const prefResolutionLevel =
        this.prefPhotoResolutionLevelMap[deviceId] ?? PhotoResolutionLevel.FULL;
    if (photoResoltionOptions.find(
            (option) => option.resolutionLevel === prefResolutionLevel) !==
        undefined) {
      return prefResolutionLevel;
    }
    return photoResoltionOptions[0].resolutionLevel;
  }

  private getPhotoOptionsGroup(deviceId: string): PhotoResolutionOptionGroup {
    const aspectRatioOptionsMap = this.photoOptions.get(deviceId);
    assert(aspectRatioOptionsMap !== undefined);
    const facing = this.cameraInfos.get(deviceId)?.facing;
    assert(facing !== undefined);

    const chosenAspectRatioSet =
        this.getChosenAspectRatio(deviceId, aspectRatioOptionsMap);
    const options = aspectRatioOptionsMap.get(chosenAspectRatioSet);
    assert(options !== undefined);
    const prefResolutionLevel =
        this.getPreferredPhotoResolutionLevel(deviceId, options);
    const prefResolution =
        this.getPreferPhotoResolution(deviceId, chosenAspectRatioSet);
    for (const option of options) {
      // Select the level corresponding to current resolution for opening
      // camera. Otherwise, select according to the user preference.
      if (deviceId === this.cameraConfig?.deviceId &&
          this.cameraConfig?.mode !== Mode.VIDEO) {
        const currentResolution =
            this.cameraConfig.captureCandidate?.resolution;
        assert(currentResolution !== null);
        option.checked =
            option.resolutions.some((r) => r.equals(currentResolution));
      } else {
        if (expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
          option.checked = option.resolutions[0].equals(prefResolution);
        } else {
          option.checked = option.resolutionLevel === prefResolutionLevel;
        }
      }
    }
    return {deviceId, facing, options};
  }

  private getPhotoOptionsGroupForCrop(deviceId: string):
      PhotoResolutionOptionGroup {
    const facing = this.cameraInfos.get(deviceId)?.facing;
    assert(facing !== undefined);

    const options = this.photoOptionsForCrop.get(deviceId);
    assert(options !== undefined);

    const prefResolutionLevel =
        this.getPreferredPhotoResolutionLevel(deviceId, options);
    const prefResolution =
        this.getPreferPhotoResolution(deviceId, AspectRatioSet.RATIO_SQUARE);
    for (const option of options) {
      if (expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
        option.checked = option.resolutions[0].equals(prefResolution);
      } else {
        option.checked = option.resolutionLevel === prefResolutionLevel;
      }
    }
    return {deviceId, facing, options};
  }

  /**
   * Notifies listeners for the new options changes according to the built
   * options and the current camera config.
   */
  private notifyListeners(): void {
    this.notifyPhotoResolutionListeners();
    this.notifyPhotoAspectRatioListeners();
    this.notifyVideoResolutionListeners();
  }

  private notifyPhotoResolutionListeners(): void {
    const groups = [];
    for (const deviceId of this.photoOptions.keys()) {
      if (this.prefPhotoAspectRatioSetMap[deviceId] ===
          AspectRatioSet.RATIO_SQUARE) {
        groups.push(this.getPhotoOptionsGroupForCrop(deviceId));
      } else {
        groups.push(this.getPhotoOptionsGroup(deviceId));
      }
    }
    for (const listener of this.photoResolutionOptionListeners) {
      listener(groups);
    }
  }

  private notifyPhotoAspectRatioListeners(): void {
    const groups = [];
    for (const [deviceId, aspectRatioOptionsMap] of this.photoOptions
             .entries()) {
      const facing = this.cameraInfos.get(deviceId)?.facing;
      assert(facing !== undefined);

      const chosenAspectRatioSet =
          this.getChosenAspectRatio(deviceId, aspectRatioOptionsMap);
      const options = [];
      // Always put a "Square" option in the aspect ratio options.
      for (const aspectRatioSet
               of [...aspectRatioOptionsMap.keys(),
                   AspectRatioSet.RATIO_SQUARE]) {
        options.push({
          aspectRatioSet,
          checked: aspectRatioSet === chosenAspectRatioSet,
        });
      }
      groups.push({deviceId, facing, options});
    }
    for (const listener of this.photoAspectRatioOptionListeners) {
      listener(groups);
    }
  }

  private notifyVideoResolutionListeners(): void {
    const groups = [];
    for (const [deviceId, options] of this.videoOptions.entries()) {
      const facing = this.cameraInfos.get(deviceId)?.facing;
      assert(facing !== undefined);

      const prefLevel = this.prefVideoResolutionLevelMap[deviceId] ??
          getFallbackVideoResolutionLevel(options);
      const prefResolution = this.prefVideoResolutionMap[deviceId] ?? null;
      for (const option of options) {
        if (this.cameraConfig === null) {
          continue;
        }
        const prefFps = this.getFallbackFPS(deviceId, option.resolutionLevel);
        const captureCandidate = this.cameraConfig.captureCandidate;
        const configuredResolution = captureCandidate?.resolution;
        const isRunningCameraOption = deviceId === this.cameraConfig.deviceId &&
            configuredResolution !== null &&
            option.fpsOptions.some(
                (fpsOption) => fpsOption.resolutions.some(
                    (r) => r.equals(configuredResolution)));
        // Select the level corresponding to current resolution for opening
        // camera. Otherwise, select according to the use user preference.
        if (deviceId === this.cameraConfig.deviceId &&
            this.cameraConfig?.mode === Mode.VIDEO) {
          option.checked = isRunningCameraOption;
        } else {
          if (expert.isEnabled(expert.ExpertOption.SHOW_ALL_RESOLUTIONS)) {
            option.checked = option.fpsOptions.some(
                (fpsOption) => fpsOption.resolutions[0].equals(prefResolution));
          } else {
            option.checked = option.resolutionLevel === prefLevel;
          }
        }
        for (const fpsOption of option.fpsOptions) {
          if (isRunningCameraOption) {
            fpsOption.checked =
                fpsOption.constFps === captureCandidate.getConstFps();
          } else {
            fpsOption.checked = fpsOption.constFps === prefFps;
          }
        }
      }
      groups.push({deviceId, facing, options});
    }
    for (const listener of this.videoResolutionOptionListeners) {
      listener(groups);
    }
  }

  private getFallbackFPS(deviceId: string, level: VideoResolutionLevel):
      number {
    return this.prefVideoFpsesMap[deviceId]?.[level] ?? 30;
  }

  private getPreferPhotoResolution(
      deviceId: string, aspectRatioSet: AspectRatioSet): Resolution|null {
    const map = this.prefPhotoResolutionMap[deviceId];
    if (map === undefined) {
      return null;
    }

    const entry = map[aspectRatioSet];
    return entry !== undefined ? new Resolution(entry.width, entry.height) :
                                 null;
  }

  private setPreferPhotoResolution(
      deviceId: string, aspectRatioSet: AspectRatioSet,
      resolution: Resolution): void {
    this.prefPhotoResolutionMap[deviceId] = {
      ...this.prefPhotoResolutionMap[deviceId],
      [aspectRatioSet]: resolution,
    };
  }
}

function getFallbackAspectRatioSet(
    aspectRatioOptionsMap: Map<AspectRatioSet, PhotoResolutionOption[]>,
    preferAspectRatioSetOrder: AspectRatioSet[]): AspectRatioSet {
  for (const aspectRatioSet of preferAspectRatioSetOrder) {
    if (aspectRatioOptionsMap.has(aspectRatioSet)) {
      return aspectRatioSet;
    }
  }
  assertNotReached();
}

function getFallbackVideoResolutionLevel(options: VideoResolutionOption[]):
    VideoResolutionLevel {
  const preferenceOrder = [
    VideoResolutionLevel.FOUR_K,
    VideoResolutionLevel.QUAD_HD,
    VideoResolutionLevel.FULL_HD,
    VideoResolutionLevel.HD,
    VideoResolutionLevel.THREE_SIXTY_P,
    VideoResolutionLevel.FULL,
    VideoResolutionLevel.MEDIUM,
  ];
  for (const level of preferenceOrder) {
    if (options.some((option) => option.resolutionLevel === level)) {
      return level;
    }
  }
  assertNotReached();
}

function getAspectRatioSetPreferOrder() {
  const board = getBoard();
  switch (board) {
    case 'rex':
      return [
        AspectRatioSet.RATIO_16_9,
        AspectRatioSet.RATIO_4_3,
        AspectRatioSet.RATIO_OTHER,
      ];
    default:
      return [
        AspectRatioSet.RATIO_4_3,
        AspectRatioSet.RATIO_16_9,
        AspectRatioSet.RATIO_OTHER,
      ];
  }
}