油管视频旋转

油管的视频旋转插件.

// ==UserScript==
// @author          zhzLuke96
// @name            油管视频旋转
// @name:en         youtube player rotate
// @version         2.10
// @description     油管的视频旋转插件.
// @description:en  rotate youtube player.
// @namespace       https://github.com/zhzLuke96/ytp-rotate
// @match           https://www.youtube.com/*
// @grant           none
// @license         MIT
// @supportURL      https://github.com/zhzLuke96/ytp-rotate/issues
// @icon            https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// ==/UserScript==

(async function () {
  "use strict";
  // assets
  const assets = {
    locals: {
      zh: {
        click_rotate: "点击顺时针旋转视频90°",
        toggle_plugin: "开/关 ytp-rotate",
        rotate90: "旋转90°",
        cover_screen: "填充屏幕",
        flip_horizontal: "水平翻转",
        flip_vertical: "垂直翻转",
        PIP: "画中画",
        click_cover_screen: "点击 开/关 填充屏幕",
      },
      en: {
        click_rotate: "click to rotate video 90°",
        toggle_plugin: "on/off ytp-rotate",
        rotate90: "rotate 90°",
        cover_screen: "cover screen",
        flip_horizontal: "flip horizontal",
        flip_vertical: "flip vertical",
        PIP: "picture in picture",
        click_cover_screen: "click to on/off screen",
      },
    },
    icons: {
      rotate: `<svg style="transform: rotateX(180deg);" width="24px" height="24px" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
      <rect width="48" height="48" fill="white" fill-opacity="0.01"/>
      <path d="M4 24C4 35.0457 12.9543 44 24 44L19 39" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
      <path d="M44 24C44 12.9543 35.0457 4 24 4L29 9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
      <path d="M30 41L7 18L18 7L41 30L30 41Z" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>`,
      fullscreen: `<svg width="24px" height="24px" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg" class="si-glyph si-glyph-fullscreen"><g fill="currentColor" fill-rule="evenodd"><path class="si-glyph-fill" d="M3 5h12v8H3zM3.918 14.938H1v-2.876h1v1.98h1.918v.896ZM17 14.938h-2.938v-.896H16v-1.984h1v2.88ZM17 5.917h-1v-1.95h-1.943v-.946H17v2.896ZM2 5.938H1V3h2.938v.938H2v2Z"/></g></svg>`,
      flip_horizontal: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M2 18.114V5.886c0-1.702 0-2.553.542-2.832.543-.28 1.235.216 2.62 1.205l1.582 1.13c.616.44.924.66 1.09.982C8 6.694 8 7.073 8 7.83v8.34c0 .757 0 1.136-.166 1.459-.166.323-.474.543-1.09.983l-1.582 1.13c-1.385.988-2.077 1.483-2.62 1.204C2 20.666 2 19.816 2 18.114ZM22 18.114V5.886c0-1.702 0-2.553-.542-2.832-.543-.28-1.235.216-2.62 1.205l-1.582 1.13c-.616.44-.924.66-1.09.982C16 6.694 16 7.073 16 7.83v8.34c0 .757 0 1.136.166 1.459.166.323.474.543 1.09.983l1.581 1.13c1.386.988 2.078 1.483 2.62 1.204.543-.28.543-1.13.543-2.832Z"/><path fill="currentColor" fill-rule="evenodd" d="M12 1.25a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0V2a.75.75 0 0 1 .75-.75Zm0 8a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Zm0 8a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd"/></svg>`,
      flip_vertical: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M18.114 22H5.886c-1.702 0-2.553 0-2.832-.542-.28-.543.216-1.235 1.205-2.62l1.13-1.582c.44-.616.66-.924.982-1.09C6.694 16 7.073 16 7.83 16h8.34c.757 0 1.136 0 1.459.166.323.166.543.474.983 1.09l1.13 1.581c.988 1.386 1.483 2.078 1.204 2.62-.28.543-1.13.543-2.832.543ZM18.114 2H5.886c-1.702 0-2.553 0-2.832.542-.28.543.216 1.235 1.205 2.62l1.13 1.582c.44.616.66.924.982 1.09C6.694 8 7.073 8 7.83 8h8.34c.757 0 1.136 0 1.459-.166.323-.166.543-.474.983-1.09l1.13-1.582c.988-1.385 1.483-2.077 1.204-2.62C20.666 2 19.816 2 18.114 2Z"/><path fill="currentColor" fill-rule="evenodd" d="M1.25 12a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H2a.75.75 0 0 1-.75-.75Zm8 0a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Zm8 0a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd"/></svg>`,
      pip: `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0z"/><path fill="currentColor" d="M21 3a1 1 0 0 1 1 1v7h-2V5H4v14h6v2H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h18zm0 10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h8zm-1 2h-6v4h6v-4z"/></svg>`,
    },
  };
  const constants = {
    version: GM_info.script.version,
    user_lang:
      (
        navigator.language ||
        navigator.browserLanguage ||
        navigator.systemLanguage
      ).toLowerCase() || "",
    style_rule_name: "ytp_player_rotate_user_js",
  };

  const $ = (q) => document.querySelector(q);
  const i18n = (x) =>
    assets.locals[constants.user_lang.includes("zh") ? "zh" : "en"][x] || x;
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  function $css(style_obj, important = true) {
    return (
      Object.entries(style_obj || {})
        // transform用array储存属性
        .map(([k, v]) => [k, Array.isArray(v) ? v.join(" ") : v])
        .map(([k, v]) => `${k}:${v} ${important ? "!important" : ""};`)
        .join("\n")
    );
  }

  // NOTE: 为了解决 `This document requires 'TrustedHTML' assignment.` 问题
  // 首先,检查 trustedTypes 是否可用
  let trusted_policy = null;
  if (window.trustedTypes) {
    // 如果默认策略不存在,创建一个新的默认策略
    if (!window.trustedTypes.defaultPolicy) {
      try {
        window.trustedTypes.createPolicy("default", {
          createHTML: (string, sink) => {
            return string;
          },
        });
      } catch (error) {
        console.error("Failed to create default Trusted Types policy:", error);
      }
    }

    // 如果默认策略存在但没有 createHTML 方法,尝试添加这个方法
    if (
      window.trustedTypes.defaultPolicy &&
      !window.trustedTypes.defaultPolicy.createHTML
    ) {
      try {
        Object.defineProperty(window.trustedTypes.defaultPolicy, "createHTML", {
          value: function (string, sink) {
            return string;
          },
          writable: false,
          enumerable: true,
          configurable: false,
        });
      } catch (error) {
        console.error(
          "Failed to add createHTML to default Trusted Types policy:",
          error
        );
      }
    }

    try {
      trusted_policy = window.trustedTypes.createPolicy("safe", {
        createHTML: (string, sink) => {
          return string;
        },
      });
    } catch (error) {
      console.error("Failed to create default Trusted Types policy:", error);
    }
  }

  /**
   * @param {string} string
   * @returns {string | TrustedHTML}
   */
  function trusted_html(string) {
    if (window.trustedTypes?.defaultPolicy?.createHTML) {
      try {
        return window.trustedTypes.defaultPolicy.createHTML(string);
      } catch (error) {
        // console.error("Failed to create trusted HTML:", error);
      }
      try {
        if (trusted_policy) {
          return trusted_policy.createHTML(string);
        }
      } catch (error) {
        // console.error("Failed to create trusted HTML:", error);
      }
    }
    return string;
  }

  /**
   * debounce
   *
   * @param {Function} func - The function to debounce.
   * @param {number} wait - The number of milliseconds to delay.
   * @param {boolean} [immediate=false] - Specifies whether the function should be invoked on the leading edge (`true`) or the trailing edge (`false`) of the `wait` timeout. Default is `false`.
   * @return {Function} - The debounced function.
   */
  function debounce(func, wait, immediate = false) {
    let timeout;
    return function () {
      const context = this,
        args = arguments;
      const later = function () {
        timeout = null;
        if (!immediate) func.apply(context, args);
      };
      const callNow = immediate && !timeout;
      clearTimeout(timeout);
      timeout = setTimeout(later, wait);
      if (callNow) func.apply(context, args);
    };
  }

  async function wait_for_element(selector) {
    let retry_count = 60;
    while (retry_count > 0) {
      const element = $(selector);
      if (element && element instanceof HTMLElement) {
        return element;
      } else {
        retry_count--;
        await delay(1000);
      }
    }
    throw new Error(
      `[ytp-rotate]TIMEOUT, setup failed, can't find [${selector}]`
    );
  }

  class YtdApp {
    static EVENT_onReady = "onReady";
    static EVENT_innertubeCommand = "innertubeCommand";
    static EVENT_onOrchestrationBecameLeader = "onOrchestrationBecameLeader";
    static EVENT_onOrchestrationLostLeader = "onOrchestrationLostLeader";
    static EVENT_onOfflineOperationFailure = "onOfflineOperationFailure";
    static EVENT_SIZE_CLICKED = "SIZE_CLICKED";
    static EVENT_onFullerscreenEduClicked = "onFullerscreenEduClicked";
    static EVENT_onStateChange = "onStateChange";
    static EVENT_onPlayVideo = "onPlayVideo";
    static EVENT_onAutonavChangeRequest = "onAutonavChangeRequest";
    static EVENT_onVideoDataChange = "onVideoDataChange";
    static EVENT_onCollapseMiniplayer = "onCollapseMiniplayer";
    static EVENT_cinematicSettingsToggleChange =
      "cinematicSettingsToggleChange";
    static EVENT_onFeedbackStartRequest = "onFeedbackStartRequest";
    static EVENT_onFeedbackArticleRequest = "onFeedbackArticleRequest";
    static EVENT_onYpcContentRequest = "onYpcContentRequest";
    static EVENT_onAutonavPauseRequest = "onAutonavPauseRequest";
    static EVENT_onAdStateChange = "onAdStateChange";
    static EVENT_CONNECTION_ISSUE = "CONNECTION_ISSUE";
    static EVENT_SUBSCRIBE = "SUBSCRIBE";
    static EVENT_UNSUBSCRIBE = "UNSUBSCRIBE";
    static EVENT_onYtShowToast = "onYtShowToast";
    static EVENT_onFullscreenChange = "onFullscreenChange";
    static EVENT_onAbnormalityDetected = "onAbnormalityDetected";
    static EVENT_onAutonavCoundownStarted = "onAutonavCoundownStarted";
    static EVENT_updateEngagementPanelAction = "updateEngagementPanelAction";
    static EVENT_changeEngagementPanelVisibility =
      "changeEngagementPanelVisibility";
    static EVENT_onVideoProgress = "onVideoProgress";

    static PlayerStates = {
      [2]: "paused",
      [3]: "playing",
      [5]: "cued",
    };

    // 这个组件是全局单例,页面不关闭都存在
    $root = wait_for_element("ytd-app");

    // inner ytd-player instance
    /**
     * @type {YtdInstance}
     */
    _ytd_player_ = null;
    $player_root = null;

    $right_controls = this.wait_for_element(".ytp-right-controls");
    $left_controls = this.wait_for_element(".ytp-left-controls");
    $settings_button = this.wait_for_element(".ytp-settings-button");

    ready = new Promise(async (resolve, reject) => {
      const query_player = async () => {
        const root = await this.$root;
        this.$player_root = root.querySelector(".html5-video-player");
        if (this.$player_root) {
          resolve();
          return this.$player_root;
        }
      };
      const instance = await this.ytd_player_instance();
      if (!instance) {
        reject(new Error("can't find ytd-player instance"));
        return;
      }
      instance.addEventListener(YtdApp.EVENT_onReady, query_player);
      instance.addEventListener(YtdApp.EVENT_onPlayVideo, query_player);
      instance.addEventListener(YtdApp.EVENT_onVideoDataChange, query_player);
      instance.addEventListener(YtdApp.EVENT_onVideoProgress, query_player);

      // 不需要...
      // setTimeout(() => {
      //   reject(new Error("timeout"));
      // }, 1000 * 60);
    });

    async ytd_player_instance() {
      while (!this._ytd_player_) {
        const $player = await wait_for_element("ytd-player");
        this._ytd_player_ = $player.player_;
        await delay(1000);
      }
      return this._ytd_player_;
    }

    /**
     *
     * @returns {Promise<Number>}
     */
    async get_player_state() {
      const instance = await this.ytd_player_instance();
      return instance.getPlayerState();
    }

    /**
     *
     * @param {String} event
     */
    async wait_for_player_event(event) {
      const instance = await this.ytd_player_instance();
      return new Promise((resolve) => {
        instance.addEventListener(event, resolve);
      });
    }

    /**
     *
     * @param {String} selector
     * @returns {Promise<HTMLElement>}
     */
    async wait_for_element(selector) {
      await this.ready;
      const $player = await this.get_player_root();
      let element = $player.querySelector(selector);
      while (!element) {
        await delay(500);
        element = $player.querySelector(selector);
      }
      return element;
    }

    /**
     *
     * @returns {Promise<HTMLDivElement>}
     */
    async get_player_root() {
      if (this.$player_root) {
        return this.$player_root;
      }
      await this.ready;
      this.$player_root = document.querySelector(".html5-video-player");
      if (this.$player_root) {
        return this.$player_root;
      }
      throw new Error("can't find player element");
    }
  }
  const ytd_app = new YtdApp();

  class YtpPlayer {
    ui = new YtpPlayerUI();
    rotate_transform = new RotateTransform();
    $player = ytd_app.get_player_root();
    $video = null; // 从$player中获取

    enabled = true;

    constructor() {
      this.ready = this.setup();

      this.ready.then(() => {
        console.log("[ytp-rotate:player] ready");
        this.setup_observer();
      });
    }

    async setup_observer() {
      // ready 之后监听player元素变化
      this.observe_player_rerender();
      this.observe_player_resize();

      const debounce_update = debounce(() => this.update(), 300);

      // FIXME 没有gc
      window.addEventListener("resize", debounce_update);
      window.addEventListener("popstate", debounce_update);

      const instance = await ytd_app.ytd_player_instance();
      if (!instance) {
        console.warn("[ytp-rotate] can't find ytd-player instance");
        return;
      }
      instance.addEventListener(
        YtdApp.EVENT_onVideoDataChange,
        debounce_update
      );
    }

    async setup() {
      await this.$player;
      await this.mount_rotate_component();
      await this.mount_ui_component();
      this.enable();
    }

    // 监听player元素变化
    // NOTE: 加这个是因为油管的广告也是用player播放,并且播放完之后video元素不会复用...直接就删了...所以需要监听player的子元素变化
    // NOTE2: 其实理论上说css写在player上就行了,但是计算缩放需要针对特定的视频分辨率,所以还是写在video上比较好
    async observe_player_rerender() {
      if (window.MutationObserver === undefined) {
        // 有可能没有
        console.warn(
          `[ytp-rotate] MutationObserver not supported, can't observe player`
        );
        return;
      }
      const observer = new MutationObserver((mutationsList, observer) => {
        for (const mutation of mutationsList) {
          if (mutation.type === "childList") {
            const video_elem =
              mutation.target.querySelector(".html5-main-video");
            if (!video_elem) {
              continue;
            }
            if (video_elem !== this.$video) {
              this.$video = video_elem;
              this.reset_rotate_component().catch((e) =>
                console.error("[ytp-rotate] reset_rotate_component failed", e)
              );
              // FIXME 这里最好ui也reset一下,但是现在暂时不用
              // this.reset_ui_component();
            }
          }
        }
      });
      observer.observe(await this.$player, { childList: true });
    }

    async observe_player_resize() {
      if (window.ResizeObserver === undefined) {
        console.warn(
          `[ytp-rotate] ResizeObserver not supported, can't observe player`
        );
        return;
      }
      const $player = await this.$player;
      const observer = new ResizeObserver((entries) => {
        for (const entry of entries) {
          if (entry.target === $player) {
            this.update();
          }
        }
      });
      observer.observe($player);
    }

    async mount_ui_component() {
      const $player = await this.$player;
      const $video = $player.querySelector(
        ".html5-video-container .html5-main-video"
      );
      if (!$video) {
        throw new Error("can't find video element");
      }
      this.$video = $video;
      this.ui.mount($video, $player);
    }

    async mount_rotate_component() {
      const $player = await this.$player;
      const $video = $player.querySelector(
        ".html5-video-container .html5-main-video"
      );
      if (!$video) {
        throw new Error("can't find video element");
      }
      this.$video = $video;
      this.rotate_transform.mount($video, $player);
    }

    // 重置旋转组件
    // NOTE 现在只有video元素变化才会调用
    async reset_rotate_component() {
      console.warn(
        `[ytp-rotate] video element changed, reset rotate component...`
      );
      this.rotate_transform.unmount();
      this.rotate_transform = new RotateTransform();

      await this.mount_rotate_component();
    }

    async reset_ui_component() {
      console.warn(`[ytp-rotate] video element changed, reset ui component...`);
      this.ui.unmount();
      this.ui = new YtpPlayerUI();

      await this.mount_ui_component();
    }

    enable() {
      this.enabled = true;
      this.ui.enable();
      this.rotate_transform.enable();

      this.update();
    }

    disable() {
      this.enabled = false;
      this.ui.disable();
      this.rotate_transform.disable();
      this.rotate_transform.reset();

      this.update();
    }

    async is_visible() {
      return (await this.$player).getBoundingClientRect().width > 0;
    }

    async update() {
      if (!this.enabled || !(await this.is_visible())) {
        return;
      }
      await this.ready;
      this.rotate_transform.update();
      this.ui.update();
      // debug
      // console.log("[ytp-rotate] update", Date.now());
    }
  }

  class YtpPlayerUI {
    key2dom = {};
    enabled = true;

    elements = [];

    buttons = [];
    menuitems = [];

    constructor() {
      // pass
    }

    mount($video, $player) {
      if (!($video instanceof HTMLVideoElement)) {
        throw new Error("$video must be a HTMLVideoElement");
      }
      if (!($player instanceof HTMLElement)) {
        throw new Error("$player must be a HTMLElement");
      }
      this.$video = $video;
      this.$player = $player;
    }

    unmount() {
      this.disable();
      for (const element of this.elements) {
        element.remove();
      }
      this.elements = [];
      this.buttons = [];
      this.menuitems = [];
      this.key2dom = {};
    }

    enable() {
      this.enabled = true;
      // for (const dom of Object.values(this.key2dom)) {
      //   dom.hidden = false;
      // }
    }

    disable() {
      this.enabled = false;

      // NOTE 因为隐藏之后menu container不会resize所以算了不隐藏了...
      // for (const [key, dom] of Object.entries(this.key2dom)) {
      //   if (key === "menu_toggle_plugin") continue;
      //   dom.hidden = true;
      // }
    }

    update() {
      for (const item of Object.values(this.menuitems)) {
        item.on_update?.();
      }
    }

    async add_button({
      html = "",
      class_name = "ytp-button",
      on_click,
      css_text = "",
      id,
      key = "",
      title = "",
      to_right = true,
    } = {}) {
      const $right_controls = await ytd_app.$right_controls;
      const $left_controls = await ytd_app.$left_controls;
      const $settings_button = await ytd_app.$settings_button;
      const $button = $settings_button.cloneNode(true);
      this.elements.push($button);

      $button.innerHTML = trusted_html(html);
      $button.classList.add(class_name);
      if (css_text) $button.style.cssText = css_text;
      if (id) $button.id = id;
      if (key) this.key2dom[key] = $button;
      if (title) {
        $button.title = title;
        $button.setAttribute("aria-label", title);
      }
      if (on_click)
        $button.addEventListener("click", async (ev) => {
          try {
            await on_click(ev);
          } catch (error) {
            console.error(error);
          }
        });
      if (to_right) {
        $right_controls.insertBefore(
          $button,
          $right_controls.firstElementChild
        );
      } else {
        $left_controls.appendChild($button);
      }
      this.buttons.push({
        $button,
        on_click,
        key,
        id,
      });
      this.button_normalize($button);

      return $button;
    }

    button_normalize($btn) {
      if (!($btn instanceof HTMLButtonElement)) {
        return;
      }
      // 移除quality-badge相关class
      for (const cls of $btn.classList) {
        if (cls.endsWith("quality-badge")) {
          $btn.classList.remove(cls);
        }
      }
      [
        "aria-controls",
        "aria-haspopup",
        "aria-expanded",
        "aria-pressed",
      ].forEach((attr) => {
        $btn.removeAttribute(attr);
      });
      ["tooltipText", "tooltipTargetId"].forEach((attr) => {
        delete $btn.dataset[attr];
      });
    }

    query_cache = {};
    // menu的query需要等待contextmenu事件再开始检测
    wait_for_menu_element(selector) {
      if (this.query_cache[selector]) {
        return this.query_cache[selector];
      }
      const dom = document.querySelector(selector);
      if (dom) {
        this.query_cache[selector] = Promise.resolve(dom);
        return Promise.resolve(dom);
      }
      return new Promise((resolve) => {
        // 因为video元素随时会销毁,所以需要监听parent上
        const target = this.$video.parentElement;
        const handler = (ev) => {
          // 如果不是右键
          if (ev.button !== 2) return;
          const domP = wait_for_element(selector);
          this.query_cache[selector] = domP;
          resolve(domP);
          target.removeEventListener("mousedown", handler);
        };
        target.addEventListener("mousedown", handler);
      });
    }

    async add_menu({
      label = "",
      content = '<div class="ytp-menuitem-toggle-checkbox"></div>',
      href = "",
      icon,
      on_click,
      key,
      on_update,
    } = {}) {
      const [$panel_menu, $panel_menu_link_tpl, $panel_menu_div_tpl] =
        await Promise.all([
          this.wait_for_menu_element(
            ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu"
          ),
          this.wait_for_menu_element(
            ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu>a.ytp-menuitem"
          ),
          this.wait_for_menu_element(
            ".ytp-contextmenu>.ytp-panel>.ytp-panel-menu>div.ytp-menuitem"
          ),
        ]);
      let $element = null;
      if (href) {
        $element = $panel_menu_link_tpl.cloneNode(true);
        $element.href = href;
      } else {
        $element = $panel_menu_div_tpl.cloneNode(true);
      }
      this.elements.push($element);

      const $label = $element.querySelector(".ytp-menuitem-label");
      const $content = $element.querySelector(".ytp-menuitem-content");
      const $icon = $element.querySelector(".ytp-menuitem-icon");
      const __on_update = (ev) =>
        on_update && on_update({ $element, $label, $content, $icon, ev });
      if (key) this.key2dom[key] = $element;
      if (label) $label.innerHTML = trusted_html(label);
      if (content) $content.innerHTML = trusted_html(content);
      if (on_click)
        $element.addEventListener("click", async (ev) => {
          try {
            await on_click?.(ev);
            await __on_update(ev);
          } catch (error) {
            console.error(error);
          }
        });
      if (icon) $icon.innerHTML = trusted_html(icon);
      $panel_menu.appendChild($element);

      this.menuitems.push({
        $element,
        on_click,
        key,
        on_update: __on_update,
      });
      return $element;
    }
  }

  class RotateTransform {
    status = {
      rotate: 0, // 0 1 2 3 => 0 90 180 270
      horizontal: false,
      vertical: false,
      // 类似于background-image的cover,但是会居中
      cover_screen: false,
    };

    styles = {
      transform: [],
    };
    $style = document.createElement("style");

    constructor() {
      this.enable();
    }

    isNoneEffect() {
      const { rotate, horizontal, vertical, cover_screen } = this.status;
      return (
        rotate === 0 &&
        horizontal === false &&
        vertical === false &&
        cover_screen === false
      );
    }

    mount($video, $player) {
      if (!($video instanceof HTMLVideoElement)) {
        throw new Error("$video must be a HTMLVideoElement");
      }
      if (!($player instanceof HTMLElement)) {
        throw new Error("$player must be a HTMLElement");
      }
      this.$video = $video;
      this.$player = $player;

      $video.classList.add(constants.style_rule_name);
    }

    unmount() {
      this.disable();
      this.$video.classList.remove(constants.style_rule_name);
      this.$video = null;
      this.$player = null;
    }

    updateRule(overwrite_str) {
      const cssText =
        overwrite_str === undefined ? $css(this.styles) : overwrite_str;
      this.$style.innerHTML = trusted_html(
        `.${constants.style_rule_name}{${cssText}}`
      );
    }

    /**
     * 计算缩放值K
     */
    calcScaleK() {
      const { $player, $video } = this;
      if (!$player || !$video) {
        throw new Error("can't find player or video element");
      }
      const [pw, ph] = [$player.clientWidth, $player.clientHeight];
      let [w, h] = [$video.clientWidth, $video.clientHeight];
      // 这里替换是因为旋转之后等于 wh 对调
      if (this.status.rotate % 2 == 1) {
        [w, h] = [h, w];
      }

      if (this.status.cover_screen) {
        // 适配w的面积
        const fit_w_size = pw * (pw / w) * h;
        // 适配h的面积
        const fit_h_size = ph * (ph / h) * w;
        if (fit_h_size > fit_w_size) {
          return ph / h;
        } else {
          return pw / w;
        }
      }

      // NOTE: 下面这个写的有点懵逼,忘记怎么算的了不改了,能用...
      // pw === w
      if (~~((pw * h) / w) <= h) {
        // 💥💥💥
        return pw / w;
      }
      // ph === h
      return ph / h;
    }

    update() {
      const { $player, $video } = this;
      if (!$player || !$video) {
        throw new Error("can't find player or video element");
      }
      if (this.isNoneEffect()) {
        // 清空副作用
        this.updateRule("");
        return;
      }

      const scaleK = this.calcScaleK();

      const transform_arr = [
        `rotate(${this.status.rotate * 90}deg)`,
        `scale(${scaleK})`,
      ];
      const append_transform = (text) => transform_arr.push(text);
      if (this.status.horizontal) {
        if (this.status.rotate % 2 == 1) append_transform("rotateX(180deg)");
        else append_transform("rotateY(180deg)");
      }
      if (this.status.vertical) {
        if (this.status.rotate % 2 == 1) append_transform("rotateY(180deg)");
        else append_transform("rotateX(180deg)");
      }
      this.styles.transform = transform_arr;

      this.updateRule();
    }

    enabled = true;
    enable() {
      this.enabled = true;
      document.getElementsByTagName("head")[0].appendChild(this.$style);
    }

    disable() {
      this.enabled = false;
      this.$style.remove();
    }

    rotate() {
      if (!this.enabled) return;
      this.status.rotate = (this.status.rotate + 1) % 4;
      this.update();

      return this.status.rotate;
    }

    toggle_horizontal() {
      if (!this.enabled) return;
      this.status.horizontal = !this.status.horizontal;
      this.update();
      return this.status.horizontal;
    }

    toggle_vertical() {
      if (!this.enabled) return;
      this.status.vertical = !this.status.vertical;
      this.update();
      return this.status.vertical;
    }

    toggle_cover_screen() {
      if (!this.enabled) return;
      this.status.cover_screen = !this.status.cover_screen;
      this.update();
      return this.status.cover_screen;
    }

    reset() {
      this.status.rotate = 0;
      this.status.horizontal = false;
      this.status.vertical = false;
      this.update();
      return this.status;
    }
  }

  async function main() {
    const player = new YtpPlayer();
    await player.ready;

    window.addEventListener("contextmenu", () => {
      player.ui.update();
    });

    // setup buttons
    await player.ui.add_button({
      html: assets.icons.rotate,
      on_click: () => player.rotate_transform.rotate(),
      css_text: $css(
        {
          display: "inline-flex",
          "align-items": "center",
          "justify-content": "center",
          width: "48px",
          height: "48px",
          color: "#fff",
          fill: "#fff",
          "vertical-align": "top",
        },
        false
      ),
      id: "rotate-btn",
      title: i18n("click_rotate"),
      key: "btn_rotate",
    });
    await player.ui.add_button({
      html: assets.icons.fullscreen,
      on_click: () => player.rotate_transform.toggle_cover_screen(),
      css_text: $css(
        {
          display: "inline-flex",
          "align-items": "center",
          "justify-content": "center",
          width: "48px",
          height: "48px",
          color: "#fff",
          fill: "#fff",
          "vertical-align": "top",
        },
        false
      ),
      id: "cover-screen-btn",
      title: i18n("click_cover_screen"),
      key: "btn_cover_screen",
    });

    // setup contextmenu
    await player.ui.add_menu({
      key: "menu_toggle_plugin",
      label: i18n("toggle_plugin"),
      icon: '<div style="text-align: center;font-size: 24px">🎠</div>',
      on_click: (ev) => {
        if (player.enabled) {
          player.disable();
        } else {
          player.enable();
          player.update();
        }
      },
      on_update: ({ $element }) => {
        $element.setAttribute("aria-checked", player.enabled.toString());
      },
    });

    // rotate menuitem
    await player.ui.add_menu({
      key: "menu_rotate",
      on_click: (ev) => {
        player.rotate_transform.rotate();
      },
      on_update: ({ $content }) => {
        $content.innerHTML = trusted_html(
          player.rotate_transform.status.rotate * 90 + "°"
        );
      },
      label: i18n("rotate90"),
      content: "0°",
      icon: assets.icons.rotate,
    });

    // cover_screen menuitem
    await player.ui.add_menu({
      key: "menu_cover_screen",
      on_click: (ev) => {
        player.rotate_transform.toggle_cover_screen();
      },
      on_update: ({ $element }) => {
        $element.setAttribute(
          "aria-checked",
          player.rotate_transform.status.cover_screen.toString()
        );
      },
      label: i18n("cover_screen"),
      icon: assets.icons.fullscreen,
    });

    // flip horizontal
    await player.ui.add_menu({
      key: "menu_horizontal",
      on_click(ev) {
        player.rotate_transform.toggle_horizontal();
      },
      on_update: ({ $element }) => {
        $element.setAttribute(
          "aria-checked",
          player.rotate_transform.status.horizontal.toString()
        );
      },
      label: i18n("flip_horizontal"),
      icon: assets.icons.flip_horizontal,
    });

    // flip vertical
    await player.ui.add_menu({
      key: "menu_vertical",
      on_click(ev) {
        player.rotate_transform.toggle_vertical();
      },
      on_update: ({ $element }) => {
        $element.setAttribute(
          "aria-checked",
          player.rotate_transform.status.vertical.toString()
        );
      },
      label: i18n("flip_vertical"),
      icon: assets.icons.flip_vertical,
    });

    // picture in picture
    await player.ui.add_menu({
      key: "menu_pip",
      on_click(ev) {
        if (document.pictureInPictureElement) {
          return document.exitPictureInPicture();
        } else {
          return $("video").requestPictureInPicture();
        }
      },
      on_update: ({ $element }) => {
        $element.setAttribute(
          "aria-checked",
          Boolean(document.pictureInPictureElement).toString()
        );
      },
      label: i18n("PIP"),
      icon: assets.icons.pip,
    });

    return player;
  }

  console.log(`[ytp-rotate] ${constants.version} (${constants.user_lang})`);
  main()
    .then(() => console.log(`[ytp-rotate] ready`))
    .catch((err) => {
      console.error("[ytp-rotate]", err);
    });
})();