/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const { PictureInPicture } = ChromeUtils.importESModule(
  "resource://gre/modules/PictureInPicture.sys.mjs"
);
const { ShortcutUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/ShortcutUtils.sys.mjs"
);
const { DeferredTask } = ChromeUtils.importESModule(
  "resource://gre/modules/DeferredTask.sys.mjs"
);
const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);

const AUDIO_TOGGLE_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.audio-toggle.enabled";
const KEYBOARD_CONTROLS_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.keyboard-controls.enabled";
const CAPTIONS_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.display-text-tracks.enabled";
const CAPTIONS_TOGGLE_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.display-text-tracks.toggle.enabled";
const TEXT_TRACK_FONT_SIZE_PREF =
  "media.videocontrols.picture-in-picture.display-text-tracks.size";
const IMPROVED_CONTROLS_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.improved-video-controls.enabled";
const SEETHROUGH_MODE_ENABLED_PREF =
  "media.videocontrols.picture-in-picture.seethrough-mode.enabled";

/**
 * The "showing" attribute means that we intentionally want to show controls
 * on the PiP window. Examples include:
 * - temporarily revealing PiP controls after initializing a PiP window.
 * - revealing PiP controls after pausing the video.
 *
 * We remove the attribute once we're ready to hide controls on the window.
 */
const SHOWING_ATTRIBUTE = "showing";
/**
 * The "keying" attribute means that the controls have keyboard focus. Set this
 * pref to keep controls visible on the PiP window until focus is gone.
 */
const KEYING_ATTRIBUTE = "keying";
/**
 * The "donthide" attribute is used to keep controls visible while users
 * interact with the PiP CC settings menu. This ensures that we don't hide
 * controls while the menu is still in use.
 */
const DONTHIDE_ATTRIBUTE = "donthide";

// Time to fade the Picture-in-Picture video controls after first opening.
const CONTROLS_FADE_TIMEOUT_MS = 3000;
const RESIZE_DEBOUNCE_RATE_MS = 500;

/**
Quadrants!
* 2 | 1
* 3 | 4
*/
const TOP_RIGHT_QUADRANT = 1;
const TOP_LEFT_QUADRANT = 2;
const BOTTOM_LEFT_QUADRANT = 3;
const BOTTOM_RIGHT_QUADRANT = 4;

/**
 * Public function to be called from PictureInPicture.sys.mjs. This is the main
 * entrypoint for initializing the player window.
 *
 * @param {number} id
 *   A unique numeric ID for the window, used for Telemetry Events.
 * @param {WindowGlobalParent} wgp
 *   The WindowGlobalParent that is hosting the originating video.
 * @param {ContentDOMReference} videoRef
 *    A reference to the video element that a Picture-in-Picture window
 *    is being created for
 * @param {boolean} isPipApiRequest
 *    True when this PiP window was requested via the PiP web API
 *    (HTMLVideoElement.requestPictureInPicture()).
 * @returns {{ actor: PictureInPictureParent, setupPromise: Promise<void> }}
 */
function setupPlayer(id, wgp, videoRef, isPipApiRequest, autoFocus) {
  return Player.init(id, wgp, videoRef, isPipApiRequest, autoFocus);
}

/**
 * Public function to be called from PictureInPicture.sys.mjs. This update the
 * controls based on whether or not the video is playing.
 *
 * @param {boolean} isPlaying
 *   True if the Picture-in-Picture video is playing.
 */
function setIsPlayingState(isPlaying) {
  Player.isPlaying = isPlaying;
}

/**
 * Public function to be called from PictureInPicture.sys.mjs. This update the
 * controls based on whether or not the video is muted.
 *
 * @param {boolean} isMuted
 *   True if the Picture-in-Picture video is muted.
 */
function setIsMutedState(isMuted) {
  Player.isMuted = isMuted;
}

/**
 * Function to resize and reposition the PiP window
 *
 * @param {object} rect
 *   An object containing `left`, `top`, `width`, and `height` for the PiP
 *   window
 */
function resizeToVideo(rect) {
  Player.resizeToVideo(rect);
}

/**
 * Returns an object containing `left`, `top`, `width`, and `height` of the
 * PiP window before entering fullscreen. Will be null if the PiP window is
 * not in fullscreen.
 */
function getDeferredResize() {
  return Player.deferredResize;
}

function enableSubtitlesButton() {
  Player.enableSubtitlesButton();
}

function disableSubtitlesButton() {
  Player.disableSubtitlesButton();
}

function setScrubberPosition(position) {
  Player.setScrubberPosition(position);
}

function setTimestamp(timeString) {
  Player.setTimestamp(timeString);
}

function setVolume(volume) {
  Player.setVolume(volume);
}

function closeFromForeground() {
  Player.closeFromForeground();
}

/**
 * The Player object handles initializing the player, holds state, and handles
 * events for updating state.
 */
let Player = {
  _isInitialized: false,
  WINDOW_EVENTS: [
    "click",
    "contextmenu",
    "command",
    "dblclick",
    "keydown",
    "mouseup",
    "mousemove",
    "MozDOMFullscreen:Entered",
    "MozDOMFullscreen:Exited",
    "resize",
    "unload",
    "draggableregionleftmousedown",
  ],
  actor: null,
  /**
   * Used for resizing Telemetry to avoid recording an event for every resize
   * event. Instead, we wait until RESIZE_DEBOUNCE_RATE_MS has passed since the
   * last resize event before recording.
   */
  resizeDebouncer: null,
  /**
   * Used for Telemetry to identify the window.
   */
  id: -1,

  /**
   * When set to a non-null value, a timer is scheduled to hide the controls
   * after CONTROLS_FADE_TIMEOUT_MS milliseconds.
   */
  showingTimeout: null,

  /**
   * Used to determine old window location when mouseup-ed for corner
   * snapping drag vector calculation
   */
  oldMouseUpWindowX: window.screenX,
  oldMouseUpWindowY: window.screenY,

  /**
   * Used to determine if hovering the mouse cursor over the pip window or not.
   * Gets updated whenever a new hover state is detected.
   */
  isCurrentHover: false,

  /**
   * Store the size and position of the window before entering fullscreen and
   * use this to correctly position the window when exiting fullscreen
   */
  deferredResize: null,

  /**
   * Set a shortcut that can be used for unpiping without pausing
   */
  isUnpipWithoutPauseShortcut: e => e.shiftKey === true,

  /**
   * Initializes the player browser, and sets up the initial state.
   *
   * @param {number} id
   *   A unique numeric ID for the window, used for Telemetry Events.
   * @param {WindowGlobalParent} wgp
   *   The WindowGlobalParent that is hosting the originating video.
   * @param {ContentDOMReference} videoRef
   *   A reference to the video element that a Picture-in-Picture window
   *   is being created for
   * @param {boolean} isPipApiRequest
   *   True when this PiP window was requested via the PiP web API
   *   (HTMLVideoElement.requestPictureInPicture()).
   * @param {boolean} autoFocus
   *   Autofocus the PiP window
   * @returns {{ actor: PictureInPictureParent, setupPromise: Promise<void> }}
   *   Return the actor associated with this and the promise from the request
   *   of setting up the player in the child.
   */
  init(id, wgp, videoRef, isPipApiRequest, autoFocus) {
    this.id = id;

    // State for whether or not we are adjusting the time via the scrubber
    this.scrubbing = false;

    let holder = document.querySelector(".player-holder");
    let browser = document.getElementById("browser");
    browser.remove();

    browser.setAttribute("nodefaultsrc", "true");

    this.setupTooltip("close", "pictureinpicture-close-btn", "closeShortcut");
    let strId = this.isFullscreen
      ? `pictureinpicture-exit-fullscreen-btn2`
      : `pictureinpicture-fullscreen-btn2`;
    this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");

    // Set the specific remoteType and browsingContextGroupID to use for the
    // initial about:blank load. The combination of these two properties will
    // ensure that the browser loads in the same process as our originating
    // browser.
    browser.setAttribute("remoteType", wgp.domProcess.remoteType);
    browser.setAttribute(
      "initialBrowsingContextGroupId",
      wgp.browsingContext.group.id
    );
    holder.appendChild(browser);

    // dimensions set on the contentWindow so that web content knows the size when it gets opened
    // otherwise it'll be reported as 0,0 until first resize.
    const initDimension = {
      width: window.innerWidth,
      height: window.innerHeight,
    };

    this.actor =
      browser.browsingContext.currentWindowGlobal.getActor("PictureInPicture");
    const setupPromise = this.actor.sendQuery("PictureInPicture:SetupPlayer", {
      videoRef,
      isPipApiRequest,
      initDimension,
    });

    PictureInPicture.weakPipToWin.set(this.actor, window);

    for (let eventType of this.WINDOW_EVENTS) {
      addEventListener(eventType, this);
    }

    this.controls.addEventListener("mouseleave", () => {
      this.onMouseLeave();
    });
    this.controls.addEventListener("mouseenter", () => {
      this.onMouseEnter();
    });

    this.scrubber.addEventListener("input", event => {
      this.handleScrubbing(event);
    });
    this.scrubber.addEventListener("change", event => {
      this.handleScrubbingDone(event);
    });

    this.audioScrubber.addEventListener("input", event => {
      this.audioScrubbing = true;
      this.handleAudioScrubbing(event.target.value);
    });
    this.audioScrubber.addEventListener("change", () => {
      this.audioScrubbing = false;
    });
    this.audioScrubber.addEventListener("pointerdown", () => {
      if (this.isMuted) {
        this.audioScrubber.max = 1;
      }
    });

    for (let radio of document.querySelectorAll(
      'input[type=radio][name="cc-size"]'
    )) {
      radio.addEventListener("change", event => {
        this.onSubtitleChange(event.target.id);
      });
    }

    document
      .querySelector("#subtitles-toggle")
      .addEventListener("change", () => {
        this.onToggleChange();
      });

    // If the content process hosting the video crashes, let's
    // just close the window for now.
    browser.addEventListener("oop-browser-crashed", this);

    this.revealControls(false);

    if (Services.prefs.getBoolPref(AUDIO_TOGGLE_ENABLED_PREF, false)) {
      const audioButton = document.getElementById("audio");
      audioButton.hidden = false;

      const audioScrubber = document.getElementById("audio-scrubber");
      audioScrubber.hidden = false;
    }

    if (Services.prefs.getBoolPref(CAPTIONS_ENABLED_PREF, false)) {
      this.closedCaptionButton.hidden = false;
    }

    if (Services.prefs.getBoolPref(IMPROVED_CONTROLS_ENABLED_PREF, false)) {
      const fullscreenButton = document.getElementById("fullscreen");
      fullscreenButton.hidden = false;

      const seekBackwardButton = document.getElementById("seekBackward");
      seekBackwardButton.hidden = false;

      const seekForwardButton = document.getElementById("seekForward");
      seekForwardButton.hidden = false;

      this.scrubber.hidden = false;
      this.timestamp.hidden = false;

      const controlsBottomGradient = document.getElementById(
        "controls-bottom-gradient"
      );
      controlsBottomGradient.hidden = false;
    }

    this.alignEndControlsButtonTooltips();

    this.resizeDebouncer = new DeferredTask(() => {
      this.alignEndControlsButtonTooltips();
      this.recordEvent("resize", {
        width: window.outerWidth,
        height: window.outerHeight,
      });
    }, RESIZE_DEBOUNCE_RATE_MS);

    this.computeAndSetMinimumSize(window.outerWidth, window.outerHeight);

    // alwaysontop windows are not focused by default, so we have to do it
    // ourselves. We use requestAnimationFrame since we have to wait until the
    // window is visible before it can focus.
    if (autoFocus) {
      window.requestAnimationFrame(() => {
        window.focus();
      });
    }

    let fontSize = Services.prefs.getCharPref(
      TEXT_TRACK_FONT_SIZE_PREF,
      "medium"
    );

    // fallback to medium if the pref value is not a valid option
    if (fontSize === "small" || fontSize === "large") {
      document.querySelector(`#${fontSize}`).checked = "true";
    } else {
      document.querySelector("#medium").checked = "true";
    }

    // In see-through mode the PiP window is made semi-transparent on hover.
    if (Services.prefs.getBoolPref(SEETHROUGH_MODE_ENABLED_PREF, false)) {
      document.documentElement.classList.add("seethrough-mode");
    }

    this._isInitialized = true;

    return { actor: this.actor, setupPromise };
  },

  uninit() {
    this.resizeDebouncer.disarm();
    PictureInPicture.unload(window, this.actor);
  },

  setupTooltip(elId, l10nId, shortcutId) {
    const el = document.getElementById(elId);
    const shortcut = document.getElementById(shortcutId);
    let l10nObj = shortcut
      ? { shortcut: ShortcutUtils.prettifyShortcut(shortcut) }
      : {};
    document.l10n.setAttributes(el, l10nId, l10nObj);
  },

  handleEvent(event) {
    switch (event.type) {
      case "click": {
        // Don't run onClick if middle or right click is pressed respectively
        if (event.button !== 1 && event.button !== 2) {
          this.onClick(event);
          this.controls.removeAttribute(KEYING_ATTRIBUTE);
        }
        break;
      }

      case "command":
        switch (event.target.id) {
          case "View:PictureInPicture":
            this.onCommand(event);
            break;
          case "View:Fullscreen":
            this.fullscreenModeToggle(event);
            break;
        }
        break;

      case "contextmenu": {
        event.preventDefault();
        break;
      }

      case "dblclick": {
        this.onDblClick(event);
        break;
      }

      case "keydown": {
        if (event.keyCode == KeyEvent.DOM_VK_TAB) {
          this.controls.setAttribute(KEYING_ATTRIBUTE, true);
          this.showVideoControls();
        } else if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) {
          let isSettingsPanelInFocus = this.settingsPanel.contains(
            document.activeElement
          );

          event.preventDefault();

          if (!this.settingsPanel.classList.contains("hide")) {
            // If the subtitles settings panel is open, let the ESC key close it
            this.toggleSubtitlesSettingsPanel({ forceHide: true });
            if (isSettingsPanelInFocus) {
              document.getElementById("closed-caption").focus();
            }
          } else if (this.isFullscreen) {
            // We handle the ESC key, in fullscreen modus as intent to leave only the fullscreen mode
            document.exitFullscreen();
          } else {
            // We handle the ESC key, as an intent to leave the picture-in-picture modus
            this.onClose(this.isUnpipWithoutPauseShortcut(event));
          }
        } else if (
          Services.prefs.getBoolPref(KEYBOARD_CONTROLS_ENABLED_PREF, false) &&
          (event.keyCode != KeyEvent.DOM_VK_SPACE || !event.target.id)
        ) {
          // Pressing "space" fires a "keydown" event which can also trigger a control
          // button's "click" event. Handle the "keydown" event only when the event did
          // not originate from a control button and it is not a "space" keypress.
          this.onKeyDown(event);
        }

        break;
      }

      case "mouseup": {
        this.onMouseUp(event);
        break;
      }

      case "mousemove": {
        this.onMouseMove();
        break;
      }

      // Normally, the DOMFullscreenParent / DOMFullscreenChild actors
      // would take care of firing the `fullscreen-painted` notification,
      // however, those actors are only ever instantiated when a <browser>
      // is fullscreened, and not a <body> element in a parent-process
      // chrome privileged DOM window.
      //
      // Rather than trying to re-engineer JSWindowActors to be re-usable for
      // this edge-case, we do the work of firing fullscreen-painted when
      // transitioning in and out of fullscreen ourselves here.
      case "MozDOMFullscreen:Entered":
      // Intentional fall-through
      case "MozDOMFullscreen:Exited": {
        let { lastTransactionId } = window.windowUtils;
        window.addEventListener("MozAfterPaint", function onPainted(event) {
          if (event.transactionId > lastTransactionId) {
            window.removeEventListener("MozAfterPaint", onPainted);
            Services.obs.notifyObservers(window, "fullscreen-painted");
          }
        });

        // If we are exiting fullscreen we want to resize the window to the
        // stored size and position
        if (this.deferredResize && event.type === "MozDOMFullscreen:Exited") {
          this.resizeToVideo(this.deferredResize);
          this.deferredResize = null;
        }

        // Sets the title for fullscreen button when PIP is in Enter Fullscreen mode and Exit Fullscreen mode
        let strId = this.isFullscreen
          ? `pictureinpicture-exit-fullscreen-btn2`
          : `pictureinpicture-fullscreen-btn2`;
        this.setupTooltip("fullscreen", strId, "fullscreenToggleShortcut");

        window.focus();

        if (this.isFullscreen) {
          this.actor.sendAsyncMessage("PictureInPicture:EnterFullscreen", {
            isFullscreen: true,
            isVideoControlsShowing: null,
            playerBottomControlsDOMRect: null,
          });
        } else {
          this.actor.sendAsyncMessage("PictureInPicture:ExitFullscreen", {
            isFullscreen: this.isFullscreen,
            isVideoControlsShowing:
              this.controls.hasAttribute(SHOWING_ATTRIBUTE) ||
              this.controls.hasAttribute(KEYING_ATTRIBUTE),
            playerBottomControlsDOMRect:
              this.controlsBottom.getBoundingClientRect(),
          });
        }
        // The subtitles settings panel gets selected when entering/exiting fullscreen even though
        // user-select is set to none. I don't know why this happens or how to prevent so we just
        // remove the selection when fullscreen is entered/exited.
        let selection = window.getSelection();
        selection.removeAllRanges();
        break;
      }

      case "oop-browser-crashed": {
        this.closePipWindow({ reason: "BrowserCrash" });
        break;
      }

      case "resize": {
        this.onResize(event);
        break;
      }

      case "unload": {
        this.uninit();
        break;
      }

      case "draggableregionleftmousedown": {
        this.toggleSubtitlesSettingsPanel({ forceHide: true });
        break;
      }
    }
  },

  /**
   * This function handles when the scrubber is being scrubbed by the mouse
   * because if we get an input event from the keyboard, onKeyDown will set
   * this.preventNextInputEvent to true.
   * This function is called by input events on the scrubber
   *
   * @param {Event} event The input event
   */
  handleScrubbing(event) {
    // When using the keyboard to scrub, we get both a keydown and an input
    // event. The input event is fired after the keydown and we have already
    // handled the keydown event in onKeyDown so we set preventNextInputEvent
    // to true in onKeyDown as to not set the current time twice.
    if (this.preventNextInputEvent) {
      this.preventNextInputEvent = false;
      return;
    }
    if (!this.scrubbing) {
      this.wasPlaying = this.isPlaying;
      if (this.isPlaying) {
        this.actor.sendAsyncMessage("PictureInPicture:Pause");
      }
      this.scrubbing = true;
    }
    let scrubberPosition = this.getScrubberPositionFromEvent(event);
    this.setVideoTime(scrubberPosition);
  },

  /**
   * This function handles setting the scrubbing state to false and playing
   * the video if we paused it before scrubbing.
   *
   * @param {Event} event The change event
   */
  handleScrubbingDone(event) {
    if (!this.scrubbing) {
      return;
    }
    let scrubberPosition = this.getScrubberPositionFromEvent(event);
    this.setVideoTime(scrubberPosition);
    if (this.wasPlaying) {
      this.actor.sendAsyncMessage("PictureInPicture:Play");
    }
    this.scrubbing = false;
  },

  /**
   * Set the volume on the video and unmute if the video was muted.
   * If the volume is changed via the keyboard, onKeyDown will set
   * this.preventNextInputEvent to true.
   *
   * @param {number} volume A number between 0 and 1 that represents the volume
   */
  handleAudioScrubbing(volume) {
    // When using the keyboard to adjust the volume, we get both a keydown and
    // an input event. The input event is fired after the keydown event and we
    // have already handled the keydown event in onKeyDown so we set
    // preventNextInputEvent to true in onKeyDown as to not set the volume twice.
    if (this.preventNextInputEvent) {
      this.preventNextInputEvent = false;
      return;
    }

    if (this.isMuted) {
      this.isMuted = false;
      this.actor.sendAsyncMessage("PictureInPicture:Unmute");
    }

    if (volume == 0) {
      this.actor.sendAsyncMessage("PictureInPicture:Mute");
    }

    this.actor.sendAsyncMessage("PictureInPicture:SetVolume", {
      volume,
    });
  },

  getScrubberPositionFromEvent(event) {
    return event.target.value;
  },

  setVideoTime(scrubberPosition) {
    let wasPlaying = this.scrubbing ? this.wasPlaying : this.isPlaying;
    this.setScrubberPosition(scrubberPosition);
    this.actor.sendAsyncMessage("PictureInPicture:SetVideoTime", {
      scrubberPosition,
      wasPlaying,
    });
  },

  setScrubberPosition(value) {
    this.scrubber.value = value;
    this.scrubber.hidden = value === undefined;

    // Also hide the seek buttons when we hide the scrubber
    this.seekBackward.hidden = value === undefined;
    this.seekForward.hidden = value === undefined;
  },

  setTimestamp(timestamp) {
    this.timestamp.textContent = timestamp;
    this.timestamp.hidden = timestamp === undefined;
  },

  setVolume(volume) {
    if (volume < Number.EPSILON) {
      this.actor.sendAsyncMessage("PictureInPicture:Mute");
    }

    this.audioScrubber.value = volume;
  },

  closePipWindow(closeData) {
    // Set the subtitles font size prefs
    Services.prefs.setBoolPref(
      CAPTIONS_TOGGLE_ENABLED_PREF,
      document.querySelector("#subtitles-toggle").checked
    );
    for (let radio of document.querySelectorAll(
      'input[type=radio][name="cc-size"]'
    )) {
      if (radio.checked) {
        Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, radio.id);
        break;
      }
    }
    const { reason } = closeData;
    PictureInPicture.closeSinglePipWindow({ reason, actorRef: this.actor });
  },

  onDblClick(event) {
    if (event.target.id == "controls") {
      this.fullscreenModeToggle();
      event.preventDefault();
    }
  },

  onClick(event) {
    switch (event.target.id) {
      case "audio": {
        this.toggleMute();
        break;
      }

      case "close": {
        this.onClose(this.isUnpipWithoutPauseShortcut(event));
        break;
      }

      case "playpause": {
        if (!this.isPlaying) {
          this.actor.sendAsyncMessage("PictureInPicture:Play");
          this.revealControls(false);
        } else {
          this.actor.sendAsyncMessage("PictureInPicture:Pause");
          this.revealControls(true);
        }

        break;
      }

      case "seekBackward": {
        this.actor.sendAsyncMessage("PictureInPicture:SeekBackward");
        break;
      }

      case "seekForward": {
        this.actor.sendAsyncMessage("PictureInPicture:SeekForward");
        break;
      }

      case "unpip": {
        PictureInPicture.focusTabAndClosePip(window, this.actor);
        break;
      }

      case "closed-caption": {
        let options = {};
        if (event.inputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
          options.isKeyboard = true;
        }
        this.toggleSubtitlesSettingsPanel(options);
        // Early return to prevent hiding the panel below
        return;
      }

      case "fullscreen": {
        this.fullscreenModeToggle();
        this.recordEvent("fullscreen", {
          enter: !this.isFullscreen,
        });
        break;
      }

      case "font-size-selection-radio-small": {
        document.getElementById("small").click();
        break;
      }

      case "font-size-selection-radio-medium": {
        document.getElementById("medium").click();
        break;
      }

      case "font-size-selection-radio-large": {
        document.getElementById("large").click();
        break;
      }
    }
    // If the click came from a element that is not inside the subtitles settings panel
    // then we want to hide the panel
    if (!this.settingsPanel.contains(event.target)) {
      this.toggleSubtitlesSettingsPanel({ forceHide: true });
    }
  },

  /**
   * Function to toggle the visibility of the subtitles settings panel
   *
   * @param {object} options [optional] Object containing options for the function
   *   - forceHide: true to force hide the subtitles settings panel
   *   - isKeyboard: true if the subtitles button was activated using the keyboard
   *     to show or hide the subtitles settings panel
   */
  toggleSubtitlesSettingsPanel(options) {
    let settingsPanelVisible = !this.settingsPanel.classList.contains("hide");
    if (options?.forceHide || settingsPanelVisible) {
      this.settingsPanel.classList.add("hide");
      this.closedCaptionButton.setAttribute("aria-expanded", false);
      this.controls.removeAttribute(DONTHIDE_ATTRIBUTE);

      if (
        this.controls.hasAttribute(KEYING_ATTRIBUTE) ||
        this.isCurrentHover ||
        this.controls.hasAttribute(SHOWING_ATTRIBUTE)
      ) {
        return;
      }

      this.hideVideoControls();
    } else {
      this.settingsPanel.classList.remove("hide");
      this.closedCaptionButton.setAttribute("aria-expanded", true);
      this.controls.setAttribute(DONTHIDE_ATTRIBUTE, true);
      this.showVideoControls();

      if (options?.isKeyboard) {
        document.querySelector("#subtitles-toggle").focus();
      }
    }
  },

  onClose(bypassPause = false) {
    // By default, we want to pause the video on close, unless the user
    // used the assigned isUnpipWithoutPauseShortcut
    if (!bypassPause) {
      this.actor.sendAsyncMessage("PictureInPicture:Pause", {
        reason: "pip-closed",
      });
    }

    this.closePipWindow({ reason: "CloseButton" });
  },

  closeFromForeground() {
    PictureInPicture.closeSinglePipWindow({
      reason: "Foregrounded",
      actorRef: this.actor,
    });
  },

  fullscreenModeToggle() {
    if (this.isFullscreen) {
      document.exitFullscreen();
    } else {
      this.deferredResize = {
        left: window.screenX,
        top: window.screenY,
        width: window.outerWidth,
        height: window.outerHeight,
      };
      document.body.requestFullscreen();
    }
  },

  /**
   * Toggle the mute state of the video
   */
  toggleMute() {
    if (this.isMuted) {
      // We unmute in handleAudioScrubbing so no need to also do it here
      this.audioScrubber.max = 1;
      this.handleAudioScrubbing(this.lastVolume ?? 1);
    } else {
      this.lastVolume = this.audioScrubber.value;
      this.actor.sendAsyncMessage("PictureInPicture:Mute");
    }
  },

  resizeToVideo(rect) {
    if (this.isFullscreen) {
      // We store the size and position because resizing the PiP window
      // while fullscreened will cause issues
      this.deferredResize = rect;
    } else {
      let { left, top, width, height } = rect;
      window.resizeTo(width, height);
      window.moveTo(left, top);
    }
  },

  onKeyDown(event) {
    // We don't want to send a keydown event if the event target was one of the
    // font sizes in the settings panel
    if (
      event.target.parentElement?.parentElement?.classList?.contains(
        "font-size-selection"
      )
    ) {
      return;
    }

    let eventKeys = {
      altKey: event.altKey,
      shiftKey: event.shiftKey,
      metaKey: event.metaKey,
      ctrlKey: event.ctrlKey,
      keyCode: event.keyCode,
    };

    // If the up or down arrow is pressed while the scrubber is focused then we
    // want to hijack these keydown events to act as left or right arrows
    // respectively to correctly seek the video.
    if (
      event.target.id === "scrubber" &&
      event.keyCode === window.KeyEvent.DOM_VK_UP
    ) {
      eventKeys.keyCode = window.KeyEvent.DOM_VK_RIGHT;
    } else if (
      event.target.id === "scrubber" &&
      event.keyCode === window.KeyEvent.DOM_VK_DOWN
    ) {
      eventKeys.keyCode = window.KeyEvent.DOM_VK_LEFT;
    }

    // If the left or right arrow is pressed while the audio scrubber is focused
    // then we want to hijack these keydown events to act as up or down arrows
    // respectively to correctly change the volume.
    if (
      event.target.id === "audio-scrubber" &&
      event.keyCode === window.KeyEvent.DOM_VK_RIGHT
    ) {
      eventKeys.keyCode = window.KeyEvent.DOM_VK_UP;
    } else if (
      event.target.id === "audio-scrubber" &&
      event.keyCode === window.KeyEvent.DOM_VK_LEFT
    ) {
      eventKeys.keyCode = window.KeyEvent.DOM_VK_DOWN;
    }

    // If the keydown event was one of the arrow keys and the scrubber or the
    // audio scrubber was focused then we want to prevent the subsequent input
    // event from overwriting the keydown event.
    if (
      event.target.id === "audio-scrubber" ||
      (event.target.id === "scrubber" &&
        [
          window.KeyEvent.DOM_VK_LEFT,
          window.KeyEvent.DOM_VK_RIGHT,
          window.KeyEvent.DOM_VK_UP,
          window.KeyEvent.DOM_VK_DOWN,
        ].includes(event.keyCode))
    ) {
      this.preventNextInputEvent = true;
    }

    this.actor.sendAsyncMessage("PictureInPicture:KeyDown", eventKeys);
  },

  onSubtitleChange(size) {
    Services.prefs.setCharPref(TEXT_TRACK_FONT_SIZE_PREF, size);

    this.actor.sendAsyncMessage("PictureInPicture:ChangeFontSizeTextTracks");
  },

  onToggleChange() {
    // The subtitles toggle has been click in the settings panel so we toggle
    // the overlay above the font sizes and send a message to toggle the
    // visibility of the subtitles and set the toggle pref
    document
      .querySelector(".font-size-selection")
      .classList.toggle("font-size-overlay");
    this.actor.sendAsyncMessage("PictureInPicture:ToggleTextTracks");

    this.captionsToggleEnabled = !this.captionsToggleEnabled;
    Services.prefs.setBoolPref(
      CAPTIONS_TOGGLE_ENABLED_PREF,
      this.captionsToggleEnabled
    );
  },

  /**
   * PiP Corner Snapping Helper Function
   * Determines the quadrant the PiP window is currently in.
   */
  determineCurrentQuadrant() {
    // Determine center coordinates of window.
    let windowCenterX = window.screenX + window.outerWidth / 2;
    let windowCenterY = window.screenY + window.outerHeight / 2;
    let quadrant = null;
    let halfWidth = window.screen.availLeft + window.screen.availWidth / 2;
    let halfHeight = window.screen.availTop + window.screen.availHeight / 2;

    let leftHalf = windowCenterX < halfWidth;
    let rightHalf = windowCenterX > halfWidth;
    let topHalf = windowCenterY < halfHeight;
    let bottomHalf = windowCenterY > halfHeight;

    if (leftHalf && topHalf) {
      quadrant = TOP_LEFT_QUADRANT;
    } else if (rightHalf && topHalf) {
      quadrant = TOP_RIGHT_QUADRANT;
    } else if (leftHalf && bottomHalf) {
      quadrant = BOTTOM_LEFT_QUADRANT;
    } else if (rightHalf && bottomHalf) {
      quadrant = BOTTOM_RIGHT_QUADRANT;
    }
    return quadrant;
  },

  /**
   * Helper function to actually move/snap the PiP window.
   * Moves the PiP window to the top right.
   */
  moveToTopRight() {
    window.moveTo(
      window.screen.availLeft + window.screen.availWidth - window.innerWidth,
      window.screen.availTop
    );
  },

  /**
   * Moves the PiP window to the top left.
   */
  moveToTopLeft() {
    window.moveTo(window.screen.availLeft, window.screen.availTop);
  },

  /**
   * Moves the PiP window to the bottom right.
   */
  moveToBottomRight() {
    window.moveTo(
      window.screen.availLeft + window.screen.availWidth - window.innerWidth,
      window.screen.availTop + window.screen.availHeight - window.innerHeight
    );
  },

  /**
   * Moves the PiP window to the bottom left.
   */
  moveToBottomLeft() {
    window.moveTo(
      window.screen.availLeft,
      window.screen.availTop + window.screen.availHeight - window.innerHeight
    );
  },

  /**
   * Uses the PiP window's change in position to determine which direction
   * the window has been moved in.
   */
  determineDirectionDragged() {
    // Determine change in window location.
    let deltaX = this.oldMouseUpWindowX - window.screenX;
    let deltaY = this.oldMouseUpWindowY - window.screenY;
    let dragDirection = "";

    if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
      dragDirection = "draggedRight";
    } else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) {
      dragDirection = "draggedLeft";
    } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY < 0) {
      dragDirection = "draggedDown";
    } else if (Math.abs(deltaX) < Math.abs(deltaY) && deltaY > 0) {
      dragDirection = "draggedUp";
    }
    return dragDirection;
  },

  /**
   * Event handler for "mouseup" events on the PiP window.
   *
   * @param {Event} event
   *  Event context details
   */
  onMouseUp(event) {
    // Corner snapping changes start here.
    // Check if metakey pressed and macOS
    let quadrant = this.determineCurrentQuadrant();
    let dragAction = this.determineDirectionDragged();

    if (
      ((event.ctrlKey && AppConstants.platform !== "macosx") ||
        (event.metaKey && AppConstants.platform === "macosx")) &&
      dragAction
    ) {
      // Moving logic based on current quadrant and direction of drag.
      switch (quadrant) {
        case TOP_RIGHT_QUADRANT:
          switch (dragAction) {
            case "draggedRight":
              this.moveToTopRight();
              break;
            case "draggedLeft":
              this.moveToTopLeft();
              break;
            case "draggedDown":
              this.moveToBottomRight();
              break;
            case "draggedUp":
              this.moveToTopRight();
              break;
          }
          break;
        case TOP_LEFT_QUADRANT:
          switch (dragAction) {
            case "draggedRight":
              this.moveToTopRight();
              break;
            case "draggedLeft":
              this.moveToTopLeft();
              break;
            case "draggedDown":
              this.moveToBottomLeft();
              break;
            case "draggedUp":
              this.moveToTopLeft();
              break;
          }
          break;
        case BOTTOM_LEFT_QUADRANT:
          switch (dragAction) {
            case "draggedRight":
              this.moveToBottomRight();
              break;
            case "draggedLeft":
              this.moveToBottomLeft();
              break;
            case "draggedDown":
              this.moveToBottomLeft();
              break;
            case "draggedUp":
              this.moveToTopLeft();
              break;
          }
          break;
        case BOTTOM_RIGHT_QUADRANT:
          switch (dragAction) {
            case "draggedRight":
              this.moveToBottomRight();
              break;
            case "draggedLeft":
              this.moveToBottomLeft();
              break;
            case "draggedDown":
              this.moveToBottomRight();
              break;
            case "draggedUp":
              this.moveToTopRight();
              break;
          }
          break;
      } // Switch close.
    } // Metakey close.
    this.oldMouseUpWindowX = window.screenX;
    this.oldMouseUpWindowY = window.screenY;
  },

  /**
   * Event handler for mousemove the PiP Window
   */
  onMouseMove() {
    if (this.isFullscreen) {
      this.revealControls(false);
    }
  },

  onMouseEnter() {
    if (!this.isFullscreen) {
      this.isCurrentHover = true;
      this.showVideoControls();
    }
  },

  onMouseLeave() {
    if (!this.isFullscreen) {
      this.isCurrentHover = false;
      if (
        !this.controls.hasAttribute(SHOWING_ATTRIBUTE) &&
        !this.controls.hasAttribute(KEYING_ATTRIBUTE) &&
        !this.controls.hasAttribute(DONTHIDE_ATTRIBUTE)
      ) {
        this.hideVideoControls();
      }
    }
  },

  enableSubtitlesButton() {
    this.closedCaptionButton.disabled = false;

    this.alignEndControlsButtonTooltips();
    this.captionsToggleEnabled = true;
    // If the CAPTIONS_TOGGLE_ENABLED_PREF pref is false then we will click
    // the UI toggle to change the toggle to unchecked. This will call
    // onToggleChange where this.captionsToggleEnabled will be updated
    if (!Services.prefs.getBoolPref(CAPTIONS_TOGGLE_ENABLED_PREF, true)) {
      document.querySelector("#subtitles-toggle").click();
    }
  },

  disableSubtitlesButton() {
    this.closedCaptionButton.disabled = true;

    this.alignEndControlsButtonTooltips();
  },

  /**
   * Sets focus state inline end tooltip for rightmost playback controls
   */
  alignEndControlsButtonTooltips() {
    let audioBtn = document.getElementById("audio");
    let width = window.outerWidth;

    if (300 < width && width <= 400) {
      audioBtn.classList.replace("center-tooltip", "inline-end-tooltip");
    } else {
      audioBtn.classList.replace("inline-end-tooltip", "center-tooltip");
    }
  },

  /**
   * Event handler for resizing the PiP Window
   *
   * @param {Event} event
   *  Event context data object
   */
  onResize() {
    this.toggleSubtitlesSettingsPanel({ forceHide: true });
    this.resizeDebouncer.disarm();
    this.resizeDebouncer.arm();
  },

  /**
   * Event handler for user issued commands
   *
   * @param {Event} event
   *  Event context data object
   */
  onCommand() {
    this.closePipWindow({ reason: "Shortcut" });
  },

  get controls() {
    delete this.controls;
    return (this.controls = document.getElementById("controls"));
  },

  get scrubber() {
    delete this.scrubber;
    return (this.scrubber = document.getElementById("scrubber"));
  },

  get audioScrubber() {
    delete this.audioScrubber;
    return (this.audioScrubber = document.getElementById("audio-scrubber"));
  },

  get timestamp() {
    delete this.timestamp;
    return (this.timestamp = document.getElementById("timestamp"));
  },

  get controlsBottom() {
    delete this.controlsBottom;
    return (this.controlsBottom = document.getElementById("controls-bottom"));
  },

  get seekBackward() {
    delete this.seekBackward;
    return (this.seekBackward = document.getElementById("seekBackward"));
  },

  get seekForward() {
    delete this.seekForward;
    return (this.seekForward = document.getElementById("seekForward"));
  },

  get closedCaptionButton() {
    delete this.closedCaptionButton;
    return (this.closedCaptionButton =
      document.getElementById("closed-caption"));
  },

  get settingsPanel() {
    delete this.settingsPanel;
    return (this.settingsPanel = document.getElementById("settings"));
  },

  _isPlaying: false,
  /**
   * GET isPlaying returns true if the video is currently playing.
   *
   * SET isPlaying to true if the video is playing, false otherwise. This will
   * update the internal state and displayed controls.
   *
   * @type {boolean}
   */
  get isPlaying() {
    return this._isPlaying;
  },

  set isPlaying(isPlaying) {
    this._isPlaying = isPlaying;
    this.controls.classList.toggle("playing", isPlaying);
    let strId = isPlaying
      ? `pictureinpicture-pause-btn`
      : `pictureinpicture-play-btn`;
    this.setupTooltip("playpause", strId);

    if (
      !this._isInitialized ||
      // Currently, controls will always be visible on hover for non-fullscreen.
      // Only ensure we hide controls after playing state update for fullscreen,
      // by not bothering to call revealControls.
      (!this.isFullscreen && this.isCurrentHover) ||
      this.controls.hasAttribute(KEYING_ATTRIBUTE)
    ) {
      return;
    }

    if (!isPlaying) {
      this.revealControls(true);
    } else {
      this.revealControls(false);
    }
  },

  _isMuted: false,
  /**
   * GET isMuted returns true if the video is currently muted.
   *
   * SET isMuted to true if the video is muted, false otherwise. This will
   * update the internal state and displayed controls.
   *
   * @type {boolean}
   */
  get isMuted() {
    return this._isMuted;
  },

  set isMuted(isMuted) {
    this._isMuted = isMuted;
    if (!isMuted) {
      this.audioScrubber.max = 1;
    } else if (!this.audioScrubbing) {
      this.audioScrubber.max = 0;
    }
    this.controls.classList.toggle("muted", isMuted);
    let strId = isMuted
      ? `pictureinpicture-unmute-btn`
      : `pictureinpicture-mute-btn`;
    let shortcutId = isMuted ? "unMuteShortcut" : "muteShortcut";
    this.setupTooltip("audio", strId, shortcutId);
  },

  /**
   * GET isFullscreen returns true if the video is running in fullscreen mode
   *
   * @returns {boolean}
   */
  get isFullscreen() {
    return document.fullscreenElement == document.body;
  },

  /**
   * Used for recording telemetry in Picture-in-Picture.
   *
   * @param {string} type
   *   The type of PiP event being recorded.
   * @param {object} args
   *   The data to pass to telemetry when the event is recorded.
   */
  recordEvent(type, args) {
    args.value = this.id;
    Glean.pictureinpicture[type + "Player"].record(args);
  },

  /**
   * Send a message to PiPChild to adjust the subtitles position
   * so that subtitles are visible when showing video controls.
   */
  showVideoControls() {
    // offsetParent returns null when the element or any ancestor has display: none
    // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
    this.actor.sendAsyncMessage("PictureInPicture:ShowVideoControls", {
      isFullscreen: this.isFullscreen,
      isVideoControlsShowing: true,
      playerBottomControlsDOMRect: this.controlsBottom.getBoundingClientRect(),
      isScrubberShowing: !!this.scrubber.offsetParent,
    });
  },

  /**
   * Send a message to PiPChild to adjust the subtitles position
   * so that subtitles take up remaining space when hiding video controls.
   */
  hideVideoControls() {
    this.actor.sendAsyncMessage("PictureInPicture:HideVideoControls", {
      isFullscreen: this.isFullscreen,
      isVideoControlsShowing: false,
      playerBottomControlsDOMRect: null,
    });
  },

  /**
   * Makes the player controls visible.
   *
   * @param {boolean} revealIndefinitely
   *   If false, this will hide the controls again after
   *   CONTROLS_FADE_TIMEOUT_MS milliseconds has passed. If true, the controls
   *   will remain visible until revealControls is called again with
   *   revealIndefinitely set to false.
   */
  revealControls(revealIndefinitely) {
    clearTimeout(this.showingTimeout);
    this.showingTimeout = null;

    this.controls.setAttribute(SHOWING_ATTRIBUTE, true);

    if (!this.isFullscreen) {
      // revealControls() is called everytime we hover over fullscreen pip window.
      // Only communicate with pipchild when not in fullscreen mode for performance reasons.
      this.showVideoControls();
    }

    if (!revealIndefinitely) {
      this.showingTimeout = setTimeout(() => {
        const isHoverOverControlItem = this.controls.querySelector(
          ".control-item:hover"
        );
        if (this.isFullscreen && isHoverOverControlItem) {
          return;
        }
        this.controls.removeAttribute(SHOWING_ATTRIBUTE);

        if (
          !this.isFullscreen &&
          !this.isCurrentHover &&
          !this.controls.hasAttribute(KEYING_ATTRIBUTE) &&
          !this.controls.hasAttribute(DONTHIDE_ATTRIBUTE)
        ) {
          this.hideVideoControls();
        }
      }, CONTROLS_FADE_TIMEOUT_MS);
    }
  },

  /**
   * Given a width and height for a video, computes the minimum dimensions for
   * the player window, and then sets them on the root element.
   *
   * This is currently only used on Linux GTK, where the OS doesn't already
   * impose a minimum window size. For other platforms, this function is a
   * no-op.
   *
   * @param {number} width
   *   The width of the video being played.
   * @param {number} height
   *   The height of the video being played.
   */
  computeAndSetMinimumSize(width, height) {
    if (!AppConstants.MOZ_WIDGET_GTK) {
      return;
    }

    // Using inspection, these seem to be the right minimums for each dimension
    // so that the controls don't get too crowded.
    const MIN_WIDTH = 120;
    const MIN_HEIGHT = 80;

    let resultWidth = width;
    let resultHeight = height;
    let aspectRatio = width / height;

    // Take the smaller of the two dimensions, and set it to the minimum.
    // Then calculate the other dimension using the aspect ratio to get
    // both minimums.
    if (width < height) {
      resultWidth = MIN_WIDTH;
      resultHeight = Math.round(MIN_WIDTH / aspectRatio);
    } else {
      resultHeight = MIN_HEIGHT;
      resultWidth = Math.round(MIN_HEIGHT * aspectRatio);
    }

    document.documentElement.style.minWidth = resultWidth + "px";
    document.documentElement.style.minHeight = resultHeight + "px";
  },
};
