巴哈姆特動畫瘋 威力加強版

一些巴哈姆特動畫瘋的 UX 改善和小功能

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name               巴哈姆特動畫瘋 威力加強版
// @name:en            Animate-Gamer Enhancement
// @name:zh-TW         巴哈姆特動畫瘋 威力加強版
// @namespace          https://github.com/rod24574575
// @description        一些巴哈姆特動畫瘋的 UX 改善和小功能
// @description:en     Some user experience enhancement and small features for Animate-Gamer.
// @description:zh-TW  一些巴哈姆特動畫瘋的 UX 改善和小功能
// @version            1.1.2
// @license            MIT
// @author             rod24574575
// @homepage           https://github.com/rod24574575/monorepo
// @homepageURL        https://github.com/rod24574575/monorepo
// @supportURL         https://github.com/rod24574575/monorepo/issues
// @match              *://ani.gamer.com.tw/animeVideo.php*
// @run-at             document-idle
// @resource           css https://github.com/rod24574575/monorepo/raw/animate-gamer-enhancement-v1.1.2/packages/animate-gamer-enhancement/animate-gamer-enhancement.css
// @grant              GM.getValue
// @grant              GM.setValue
// @grant              GM.getResourceUrl
// ==/UserScript==

// TODO: 支援區間重複播放
// TODO: 功能/快捷鍵說明
// TODO: 提供只在此分頁有效的設定

// @ts-check
'use strict';

(function() {
  /**
   * I18n
   */

  const i18n = {
    settings_tab_name: '動畫瘋加強版',
    play_settings: '播放設定',
    auto_agree_content_rating: '自動同意分級確認',
    auto_play_next_episode: '自動播放下一集',
    auto_play_next_episode_tip: '此功能和動畫瘋內建提供的自動播放功能衝突,如果沒有自訂延遲時間的需求,可以直接使用內建的自動播放功能即可',
    auto_play_next_episode_delay: '自動播放延遲時間',
    auto_play_countdown: '倒數{0}秒繼續播放',
    interrupt_play: '中斷播放',
    second: '秒',
    timeline_automation_rule: '時間軸自動化規則',
    timeline_automation_rule_tip: '影片播放至規則所設定的時間時,會觸發該規則的指定操作。\n快捷鍵:\n[\\]帶入目前影片時間',
    add: '新增',
    advance_5s: '快轉5秒',
    advance_60s: '快轉60秒',
    rewind_5s: '倒轉5秒',
    rewind_60s: '倒轉60秒',
    switch_next_episode: '切換到下一集',
    switch_previous_episode: '切換到上一集',
  };

  /**
   * @param {keyof typeof i18n} key
   * @returns {string}
   */
  function getI18n(key) {
    return i18n[key] ?? key;
  }

  /**
   * @param {string} str
   * @param {unknown[]} args
   * @returns {string}
   */
  function formatString(str, ...args) {
    return str.replace(/\{(\d+)\}/g, (_, index) => {
      return String(args[+index]);
    });
  }

  /**
   * Settings
   */

  /**
   * @typedef {| never
   *   | 'advance_5s'
   *   | 'advance_60s'
   *   | 'rewind_5s'
   *   | 'rewind_60s'
   *   | 'switch_next_episode'
   *   | 'switch_previous_episode'
   * } Command
   */

  /**
   * @typedef ShortcutAction
   * @property {string} name
   * @property {Command} cmd
   */

  /**
   * @typedef TimelineAction
   * @property {number} time
   * @property {Command} cmd
   */

  /**
   * @typedef Settings
   * @property {boolean} autoAgreeContentRating
   * @property {boolean} autoPlayNextEpisode
   * @property {number} autoPlayNextEpisodeDelay
   * @property {ShortcutAction[]} shortcutActions
   * @property {TimelineAction[]} timelineActions
   */

  /**
   * @returns {Promise<Settings>}
   */
  async function loadSettings() {
    /** @type {Settings} */
    const settings = {
      autoAgreeContentRating: false,
      autoPlayNextEpisode: false,
      autoPlayNextEpisodeDelay: 5,
      shortcutActions: [
        {
          name: 'PageUp',
          cmd: 'switch_previous_episode',
        },
        {
          name: 'PageDown',
          cmd: 'switch_next_episode',
        },
      ],
      timelineActions: [],
    };

    const entries = await Promise.all(
      Object.entries(settings).map(async ([key, value]) => {
        try {
          value = await GM.getValue(key, value);
        } catch (e) {
          console.warn(e);
        }
        return /** @type {[string, any]} */ ([key, value]);
      }),
    );
    return /** @type {Settings} */ (Object.fromEntries(entries));
  }

  /**
   * @param {Partial<Settings>} settings
   */
  async function saveSettings(settings) {
    await Promise.allSettled(
      Object.entries(settings).map(async ([name, value]) => {
        return GM.setValue(name, value);
      }),
    );
  }

  /**
   * Store
   */

  /**
   * @typedef {HTMLElement} VjsPlayerElement
   */

  /**
   * @param {VjsPlayerElement} vjsPlayer
   */
  function useCommand(vjsPlayer) {
    const videoEl = vjsPlayer.querySelector('video');

    /**
     * @param {number} second
     */
    function advance(second) {
      if (videoEl) {
        videoEl.currentTime += second;
      }
    }

    /**
     * @param {number} second
     */
    function rewind(second) {
      if (videoEl) {
        videoEl.currentTime -= second;
      }
    }

    function switchPreviousEpisode() {
      /** @type {HTMLButtonElement | null} */
      const button = vjsPlayer.querySelector('button.vjs-pre-button');
      button?.click();
    }

    function switchNextEpisode() {
      /** @type {HTMLButtonElement | null} */
      const button = vjsPlayer.querySelector('button.vjs-next-button');
      button?.click();
    }

    /**
     * @param {Command} cmd
     * @returns {boolean}
     */
    function execCommand(cmd) {
      switch (cmd) {
        case 'advance_5s':
          advance(5);
          break;
        case 'advance_60s':
          advance(60);
          break;
        case 'rewind_5s':
          rewind(5);
          break;
        case 'rewind_60s':
          rewind(60);
          break;
        case 'switch_next_episode':
          switchNextEpisode();
          break;
        case 'switch_previous_episode':
          switchPreviousEpisode();
          break;
        default:
          return false;
      }
      return true;
    }

    return {
      execCommand,
    };
  }

  /**
   * @param {VjsPlayerElement} vjsPlayer
   */
  function useContentRating(vjsPlayer) {
    let enabled = false;

    function agreeContentRating() {
      /** @type {HTMLButtonElement | null} */
      const button = vjsPlayer.querySelector('button.choose-btn-agree');
      button?.click();
    }

    /** @type {MutationObserver | undefined} */
    let contentRatingMutationObserver;

    function onAutoAgreeContentRatingChange() {
      contentRatingMutationObserver?.disconnect();

      if (enabled) {
        agreeContentRating();

        contentRatingMutationObserver = new MutationObserver(() => {
          agreeContentRating();
        });
        contentRatingMutationObserver.observe(vjsPlayer, {
          childList: true,
        });
      }
    }

    /**
     * @param {boolean} value
     */
    function enableAutoAgreeContentRating(value) {
      if (enabled === value) {
        return;
      }
      enabled = value;
      onAutoAgreeContentRatingChange();
    }

    return {
      enableAutoAgreeContentRating,
    };
  }

  /**
   * @param {VjsPlayerElement} vjsPlayer
   */
  function useNextEpisode(vjsPlayer) {
    /**
     * @typedef {'due' | 'clear' | 'cancel'} StopCountdownReason
     */

    let enabled = false;
    let delayTime = 0;
    /** @type {{ countdownTimer: number, finishTimer: number, resolve: (reason: StopCountdownReason) => void } | null} */
    let countdownData = null;

    const videoEl = /** @type {HTMLVideoElement | null} */ (vjsPlayer.querySelector('video'));
    const stopEl = /** @type {HTMLElement | null} */ (vjsPlayer.querySelector('.stop'));
    const titleEl = /** @type {HTMLElement | null | undefined} */ (stopEl?.querySelector('#countDownTitle'));
    const nextEpisodeEl = /** @type {HTMLAnchorElement | null | undefined} */ (stopEl?.querySelector('a#nextEpisode'));
    const stopAutoPlayEl = /** @type {HTMLAnchorElement | null | undefined} */ (stopEl?.querySelector('a#stopAutoPlay'));
    const nextEpisodeSvgEl = /** @type {SVGElement | null | undefined} */ (nextEpisodeEl?.querySelector('svg'));
    const nextEpisodeCountdownEl = /** @type {SVGElement | null | undefined} */ (nextEpisodeEl?.querySelector('#countDownCircle'));

    if (!videoEl || !stopEl || !titleEl || !nextEpisodeEl || !stopAutoPlayEl || !nextEpisodeSvgEl || !nextEpisodeCountdownEl) {
      console.warn('Missing elements for next episode auto play.');
    }

    /**
     * @returns {boolean}
     */
    function isStopElShown() {
      return !!stopEl && !stopEl.classList.contains('vjs-hidden');
    }

    /**
     * @param {boolean} display
     */
    function setCountdownUiDisplay(display) {
      if (nextEpisodeEl) {
        if (display) {
          nextEpisodeEl.classList.add('center-btn');
        } else {
          nextEpisodeEl.classList.remove('center-btn');
        }
      }
      if (nextEpisodeSvgEl) {
        if (display) {
          nextEpisodeSvgEl.classList.remove('is-hide');
        } else {
          nextEpisodeSvgEl.classList.add('is-hide');
        }
      }
      if (nextEpisodeCountdownEl) {
        if (display) {
          nextEpisodeCountdownEl.classList.add('is-countdown');
          nextEpisodeCountdownEl.style.animation = `circle-offset ${delayTime}s linear 1 forwards`;
        } else {
          nextEpisodeCountdownEl.classList.remove('is-countdown');
          nextEpisodeCountdownEl.style.animation = '';
        }
      }
      if (stopAutoPlayEl) {
        if (display) {
          stopAutoPlayEl.classList.remove('vjs-hidden', 'is-disabled');

          const stopAutoPlayTextEl = stopAutoPlayEl.querySelector('p');
          if (stopAutoPlayTextEl) {
            stopAutoPlayTextEl.textContent = getI18n('interrupt_play');
          }
        } else {
          stopAutoPlayEl.classList.add('vjs-hidden');
        }
      }
      updateCountdownUi(display ? delayTime : 0);
    }

    /**
     * @param {number} countdownValue
     */
    function updateCountdownUi(countdownValue) {
      if (titleEl) {
        titleEl.textContent = countdownValue ? formatString(getI18n('auto_play_countdown'), countdownValue) : '';
      }
    }

    /**
     * @returns {Promise<StopCountdownReason>}
     */
    async function countdown() {
      clearCountdown();

      setCountdownUiDisplay(true);

      let countdownValue = delayTime;
      const countdownTimer = window.setInterval(() => {
        --countdownValue;
        updateCountdownUi(countdownValue);
      }, 1000);

      /** @type {ReturnType<typeof Promise.withResolvers<StopCountdownReason>>} */
      const { promise, resolve } = Promise.withResolvers();
      const finishTimer = window.setTimeout(() => stopCountdown('due'), delayTime * 1000);

      countdownData = {
        countdownTimer,
        finishTimer,
        resolve,
      };

      const reason = await promise;
      if (reason !== 'clear') {
        setCountdownUiDisplay(false);
      }

      return reason;
    }

    /**
     * @param {StopCountdownReason} reason
     */
    function stopCountdown(reason) {
      if (!countdownData) {
        return;
      }

      const { countdownTimer, finishTimer, resolve } = countdownData;
      window.clearInterval(countdownTimer);
      window.clearTimeout(finishTimer);
      resolve(reason);

      countdownData = null;
    }

    function clearCountdown() {
      return stopCountdown('clear');
    }

    function cancelCountdown() {
      return stopCountdown('cancel');
    }

    /**
     * @returns {Promise<void>}
     */
    async function maybePlayNextEpisode() {
      if (!isStopElShown()) {
        return;
      }

      if (delayTime) {
        const reason = await countdown();

        // Check again whether the stop element is still shown after the countdown.
        if (reason !== 'due' || !isStopElShown()) {
          return;
        }
      }

      nextEpisodeEl?.click();
    }

    /** @type {MutationObserver | undefined} */
    let nextEpisodeMutationObserver;

    function onAutoPlayNextEpisodeChange() {
      if (!stopEl) {
        return;
      }

      nextEpisodeMutationObserver?.disconnect();
      if (enabled) {
        nextEpisodeMutationObserver = new MutationObserver((records) => {
          for (const { type, target, oldValue } of records) {
            // Only handle the class attribute change of the stop element when
            // it becomes visible.
            if (type !== 'attributes' || target !== stopEl || oldValue === null || !oldValue.split(' ').includes('vjs-hidden')) {
              continue;
            }
            maybePlayNextEpisode();
          }
        });
        nextEpisodeMutationObserver.observe(stopEl, {
          attributes: true,
          attributeFilter: ['class'],
          attributeOldValue: true,
        });

        maybePlayNextEpisode();
      } else {
        cancelCountdown();
      }

      if (videoEl) {
        if (enabled) {
          videoEl.addEventListener('emptied', clearCountdown);
        } else {
          videoEl.removeEventListener('emptied', clearCountdown);
        }
      }

      if (stopAutoPlayEl) {
        if (enabled) {
          stopAutoPlayEl.addEventListener('click', cancelCountdown);
        } else {
          stopAutoPlayEl.removeEventListener('emptied', cancelCountdown);
        }
      }
    }

    /**
     * @param {boolean} value
     */
    function enableAutoPlayNextEpisode(value) {
      if (enabled === value) {
        return;
      }
      enabled = value;
      onAutoPlayNextEpisodeChange();
    }

    /**
     * @param {number} value
     */
    function setAutoPlayNextEpisodeDelay(value) {
      if (!isFinite(value)) {
        return;
      }

      value = Math.round(value);
      if (delayTime === value) {
        return;
      }
      delayTime = value;
    }

    return {
      enableAutoPlayNextEpisode,
      setAutoPlayNextEpisodeDelay,
    };
  }

  /**
   * @param {VjsPlayerElement} vjsPlayer
   */
  function useShortcuts(vjsPlayer) {
    /** @type {Map<string, Command>} */
    const customShortcutMap = new Map();
    /** @type {Map<string, Array<() => void>>} */
    const localShortcutMap = new Map();
    const commandStore = useCommand(vjsPlayer);

    /**
     * @param {KeyboardEvent} e
     * @returns {string}
     */
    function getKeyName(e) {
      return e.key;
    }

    /**
     * @param {KeyboardEvent} e
     */
    function getKeyModifier(e) {
      /** @type {string} */
      let str = '';
      if (e.shiftKey) {
        str = 'Shift-' + str;
      }
      if (e.ctrlKey) {
        str = 'Ctrl-' + str;
      }
      if (e.metaKey) {
        str = 'Meta-' + str;
      }
      if (e.altKey) {
        str = 'Alt-' + str;
      }
      return str;
    }

    /**
     * @param {KeyboardEvent} e
     * @returns {string}
     */
    function getKeyFullName(e) {
      return getKeyModifier(e) + getKeyName(e);
    }

    /**
     * @param {KeyboardEvent} e
     */
    function onKeyDown(e) {
      if (e.defaultPrevented) {
        return;
      }

      const name = getKeyFullName(e);

      const cmd = customShortcutMap.get(name);
      if (cmd) {
        commandStore.execCommand(cmd);
        e.preventDefault();
        return;
      }

      const handlers = localShortcutMap.get(name);
      if (handlers && handlers.length > 0) {
        for (const handler of handlers) {
          handler();
        }
        e.preventDefault();
        return;
      }
    }

    /**
     * @param {readonly ShortcutAction[]} value
     */
    function setCustomShortcuts(value) {
      customShortcutMap.clear();
      for (const { name, cmd } of value) {
        customShortcutMap.set(name, cmd);
      }
    }

    /**
     * @param {Record<string, Array<() => void>>} value
     */
    function addLocalShortcuts(value) {
      for (const [name, newHandlers] of Object.entries(value)) {
        let handlers = localShortcutMap.get(name);
        if (!handlers) {
          handlers = [];
          localShortcutMap.set(name, handlers);
        }
        handlers.push(...newHandlers);
      }
    }

    vjsPlayer.addEventListener('keydown', onKeyDown);

    return {
      setCustomShortcuts,
      addLocalShortcuts,
    };
  }

  /**
   * @param {VjsPlayerElement} vjsPlayer
   */
  function useTimelineActions(vjsPlayer) {
    /** @type {TimelineAction[]} */
    const timelineActions = [];

    const videoEl = /** @type {HTMLVideoElement | null} */ (vjsPlayer.querySelector('video'));
    if (videoEl) {
      videoEl.addEventListener('seeking', onVideoTimeSet);
      videoEl.addEventListener('emptied', onVideoTimeSet);
      videoEl.addEventListener('timeupdate', onVideoTimeUpdate);
    }

    let currentTime = -1;
    const commandStore = useCommand(vjsPlayer);

    function onVideoTimeSet() {
      currentTime = -1;
    }

    function onVideoTimeUpdate() {
      const oldCurrentTime = currentTime;
      const newCurrentTime = Math.floor(videoEl?.currentTime ?? 0);
      currentTime = newCurrentTime;

      if (oldCurrentTime < 0 || newCurrentTime <= oldCurrentTime) {
        return;
      }

      let fromIndex = timelineActions.findIndex((action) => (oldCurrentTime < action.time));
      if (fromIndex < 0) {
        return;
      }

      // eslint-disable-next-line no-constant-condition
      while (1) {
        const { time, cmd } = timelineActions[fromIndex];
        if (newCurrentTime < time) {
          break;
        }

        commandStore.execCommand(cmd);
        ++fromIndex;
      }
    }

    /**
     * @param {TimelineAction[]} actions
     */
    function setTimelineActions(actions) {
      timelineActions.length = 0;
      timelineActions.push(...actions);
      timelineActions.sort((a, b) => (a.time - b.time));
    }

    /**
     * @param {number} time
     * @param {Command} command
     */
    function addTimelineAction(time, command) {
      let insertIndex = timelineActions.findIndex((action) => (time < action.time));
      if (insertIndex < 0) {
        insertIndex = timelineActions.length;
      }
      timelineActions.splice(insertIndex, 0, { time, cmd: command });
    }

    /**
     * @param {number} index
     */
    function removeTimelineAction(index) {
      timelineActions.splice(index, 1);
    }

    return {
      setTimelineActions,
      addTimelineAction,
      removeTimelineAction,
    };
  }

  /**
   * @param {VjsPlayerElement} vjsPlayer
   * @param {(settings: Partial<Settings>) => void} callback
   */
  function useSettingUi(vjsPlayer, callback) {
    /**
     * @typedef SettingComponent
     * @property {Element} el
     * @property {() => void} [onMounted]
     * @property {(settings: Partial<Settings>) => void} [onSettings]
     * @property {Record<string, Array<() => void>>} [shortcuts]
     */

    const videoEl = vjsPlayer.querySelector('video');
    /** @type {readonly TimelineAction[]} */
    let timelineActions = [];
    /** @type {SettingComponent | null} */
    let tabContentComponent = null;

    const subtitleFrame = vjsPlayer.closest('.player')?.querySelector('.subtitle');

    const tabContentId = 'ani-tab-content-enhancement';

    async function attachCss() {
      const url = await GM.getResourceUrl('css');

      const linkEl = document.createElement('link');
      linkEl.rel = 'stylesheet';
      linkEl.type = 'text/css';
      linkEl.href = url;
      document.head.appendChild(linkEl);
    }

    function attachTabUi() {
      if (!subtitleFrame) {
        return;
      }

      const tabsEl = subtitleFrame.querySelector('.ani-tabs');
      if (!tabsEl) {
        return;
      }

      const tabItemEl = document.createElement('div');
      tabItemEl.classList.add('ani-tabs__item');

      const tabLinkEl = document.createElement('a');
      tabLinkEl.href = '#' + tabContentId;
      tabLinkEl.classList.add('ani-tabs-link');
      tabLinkEl.textContent = getI18n('settings_tab_name');
      tabLinkEl.addEventListener('click', function(e) {
        e.preventDefault();

        // The pure-js implementation of the same logic from the original site.

        // HACK: workaround for Plus-Ani.
        for (const el of subtitleFrame.querySelectorAll('.ani-tabs-link.is-active, .plus_ani-tabs-link.is-active')) {
          el.classList.remove('is-active');
        }
        this.classList.add('is-active');

        for (const el of /** @type {NodeListOf<HTMLElement>} */ (subtitleFrame.querySelectorAll('.ani-tab-content__item'))) {
          el.style.display = 'none';
        }

        // Must use `getAttribute` to only get the id rather than the full url.
        const targetContentEl = document.getElementById((this.getAttribute('href') ?? '').slice(1));
        if (targetContentEl) {
          targetContentEl.style.display = targetContentEl.classList.contains('setting-program') ? 'flex' : 'block';
        }
      });

      tabItemEl.appendChild(tabLinkEl);
      tabsEl.appendChild(tabItemEl);
    }

    function attachTabContentUi() {
      if (!subtitleFrame) {
        return;
      }

      const tabContentEl = subtitleFrame.querySelector('.ani-tab-content');
      if (!tabContentEl) {
        return;
      }

      /**
       * @param {number} time
       * @returns {{ hour: number, minute: number, second: number }}
       */
      function parseTime(time) {
        return {
          hour: Math.floor(time / 3600),
          minute: Math.floor(time / 60) % 60,
          second: Math.floor(time % 60),
        };
      }

      /**
       * @param {number} hour
       * @param {number} minute
       * @param {number} second
       * @returns {number}
       */
      function serializeTime(hour, minute, second) {
        return hour * 3600 + minute * 60 + second;
      }

      tabContentComponent = createSettingTabComp({
        id: tabContentId,
        sections: [
          {
            title: getI18n('play_settings'),
            items: [
              {
                type: 'checkbox',
                label: getI18n('auto_agree_content_rating'),
                value: false,
                onMounted: (el) => {
                  el.addEventListener('change', (e) => {
                    callback({ autoAgreeContentRating: el.checked });
                  });
                },
                onSettings: (el, { autoAgreeContentRating }) => {
                  if (autoAgreeContentRating !== undefined) {
                    el.checked = autoAgreeContentRating;
                  }
                },
              },
              {
                type: 'checkbox',
                label: getI18n('auto_play_next_episode'),
                labelTip: getI18n('auto_play_next_episode_tip'),
                value: false,
                onMounted: (el) => {
                  el.addEventListener('change', (e) => {
                    callback({ autoPlayNextEpisode: el.checked });
                  });
                },
                onSettings: (el, { autoPlayNextEpisode }) => {
                  if (autoPlayNextEpisode !== undefined) {
                    el.checked = autoPlayNextEpisode;
                  }
                },
              },
              {
                type: 'number',
                label: getI18n('auto_play_next_episode_delay'),
                value: 5,
                max: 10,
                min: 0,
                placeholder: getI18n('second'),
                onMounted: (el) => {
                  el.addEventListener('change', (e) => {
                    callback({ autoPlayNextEpisodeDelay: +el.value });
                  });
                },
                onSettings: (el, { autoPlayNextEpisodeDelay }) => {
                  if (autoPlayNextEpisodeDelay !== undefined) {
                    el.value = String(autoPlayNextEpisodeDelay);
                  }
                },
              },
            ],
          },
          {
            title: getI18n('timeline_automation_rule'),
            id: 'enh-ani-timeline-automation-rule',
            tip: getI18n('timeline_automation_rule_tip'),
            items: [
              {
                type: 'html',
                html: `
                  <div class="ani-setting-item">
                    <div class="enh-ani-timeline-header">
                      <div class="enh-ani-timeline-time">
                        <input type="number" id="enh-ani-timeline-time-hour" class="ani-input" placeholder="0" min="0" max="9">
                        <span class="enh-ani-time-colon">:</span>
                        <input type="number" id="enh-ani-timeline-time-minute" class="ani-input" placeholder="00" min="0" max="59">
                        <span class="enh-ani-time-colon">:</span>
                        <input type="number" id="enh-ani-timeline-time-second" class="ani-input" placeholder="00" min="0" max="59">
                      </div>
                      <div class="enh-ani-timeline-cmd btn-newanime-filter">
                        <input type="text" id="enh-ani-timeline-cmd-input" class="ani-input" readonly>
                        <ul class="filter-items"></ul>
                      </div>
                      <a href="#" role="button" class="bluebtn">${getI18n('add')}</a>
                    </div>
                    <div class="enh-ani-timeline-body">
                      <ul class="sub_list"></ul>
                    </div>
                  </div>
                `,
                onMounted: (el) => {
                  /**
                   * @param {number} value
                   * @param {number} min
                   * @param {number} max
                   * @returns {number}
                   */
                  function clamp(value, min, max) {
                    return Math.max(min, Math.min(max, value));
                  }

                  const hourInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-hour'));
                  const minuteInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-minute'));
                  const secondInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-second'));
                  const cmdInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-cmd-input'));

                  const cmdEl = el.querySelector('.enh-ani-timeline-cmd');
                  const cmdOptionsEl = el.querySelector('.filter-items');
                  const addBtnEl = el.querySelector('.bluebtn');

                  if (cmdOptionsEl) {
                    /** @type {Command[]} */
                    const cmdOptions = [
                      'advance_5s',
                      'advance_60s',
                      'rewind_5s',
                      'rewind_60s',
                      'switch_next_episode',
                      'switch_previous_episode',
                    ];
                    for (const cmd of cmdOptions) {
                      const optionEl = document.createElement('li');
                      optionEl.setAttribute('data-cmd', cmd);
                      optionEl.textContent = getI18n(cmd);
                      cmdOptionsEl.appendChild(optionEl);
                    }
                  }

                  cmdEl?.addEventListener('click', function(e) {
                    cmdOptionsEl?.classList.toggle('is-active');

                    const target = e.target;
                    if (target && target instanceof HTMLElement && cmdInputEl) {
                      const optionEl = target.closest('li');
                      if (optionEl) {
                        const parent = optionEl.parentElement;
                        if (parent) {
                          for (const child of parent.children) {
                            child.classList.remove('is-active');
                          }
                        }
                        optionEl.classList.add('is-active');

                        const cmd = /** @type {Command | undefined | null} */ (optionEl.getAttribute('data-cmd'));
                        if (cmd) {
                          cmdInputEl.setAttribute('data-cmd', cmd);
                          cmdInputEl.value = getI18n(cmd);
                        }
                      }
                    }
                  });

                  addBtnEl?.addEventListener('click', function(e) {
                    e.preventDefault();

                    const hour = hourInputEl ? clamp(Math.floor(+hourInputEl.value), 0, 9) : 0;
                    const minute = minuteInputEl ? clamp(Math.floor(+minuteInputEl.value), 0, 59) : 0;
                    const second = secondInputEl ? clamp(Math.floor(+secondInputEl.value), 0, 59) : 0;
                    if (!isFinite(hour) || !isFinite(minute) || !isFinite(second)) {
                      return;
                    }

                    const cmd = /** @type {Command | undefined | null} */ (cmdInputEl?.getAttribute('data-cmd'));
                    if (!cmd) {
                      return;
                    }

                    callback({
                      timelineActions: [
                        ...timelineActions,
                        { time: serializeTime(hour, minute, second), cmd },
                      ].sort((a, b) => (a.time - b.time)),
                    });
                  });
                },
                onSettings: (el, settings) => {
                  if (settings.timelineActions === undefined) {
                    return;
                  }

                  const ulEl = el.querySelector('.enh-ani-timeline-body')?.firstElementChild;
                  if (!ulEl) {
                    return;
                  }

                  ulEl.innerHTML = '<li class="sub-list-li">';
                  for (const [index, { time, cmd }] of timelineActions.entries()) {
                    const { hour, minute, second } = parseTime(time);
                    const timeStr = formatString(
                      '{0}:{1}:{2}',
                      String(hour),
                      String(minute).padStart(2, '0'),
                      String(second).padStart(2, '0'),
                    );

                    const dummyEl = document.createElement('div');
                    dummyEl.innerHTML = `
                      <li class="sub-list-li">
                        <b>${timeStr}</b>
                        <div class="sub_content"><span>${getI18n(cmd)}</span></div>
                        <a href="#" role="button" class="ani-keyword-close">
                          <i class="material-icons">close</i>
                        </a>
                      </li>
                    `;

                    const itemEl = /** @type {Element} */ (dummyEl.firstElementChild);
                    itemEl.querySelector('.ani-keyword-close')?.addEventListener('click', function(e) {
                      e.preventDefault();
                      callback({
                        timelineActions: timelineActions.toSpliced(index, 1),
                      });
                    });

                    ulEl.appendChild(itemEl);
                  }
                },
                shortcuts: {
                  '\\': (el) => {
                    const hourInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-hour'));
                    const minuteInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-minute'));
                    const secondInputEl = /** @type {HTMLInputElement | null} */ (document.getElementById('enh-ani-timeline-time-second'));

                    const { hour, minute, second } = parseTime(videoEl?.currentTime ?? 0);
                    if (hourInputEl) {
                      hourInputEl.value = String(hour);
                    }
                    if (minuteInputEl) {
                      minuteInputEl.value = String(minute);
                    }
                    if (secondInputEl) {
                      secondInputEl.value = String(second);
                    }
                  },
                },
              },
            ],
          },
        ],
      });

      tabContentEl.appendChild(tabContentComponent.el);
      tabContentComponent.onMounted?.();
    }

    /**
     * @template {Element} [T=Element]
     * @typedef SettingBaseConfig
     * @property {(el: T) => void} [onMounted]
     * @property {(el: T, settings: Partial<Settings>) => void} [onSettings]
     * @property {Record<string, (el: T) => void>} [shortcuts]
     */

    /**
     * @typedef _SettingCheckboxConfig
     * @property {'checkbox'} type
     * @property {string} [id]
     * @property {string} [label]
     * @property {string} [labelTip]
     * @property {boolean} [value]
     *
     * @typedef {SettingBaseConfig<HTMLInputElement> & _SettingCheckboxConfig} SettingCheckboxConfig
     */

    /**
     * @typedef _SettingNumberConfig
     * @property {'number'} type
     * @property {string} [id]
     * @property {string} [label]
     * @property {string} [labelTip]
     * @property {number} [value]
     * @property {number} [max]
     * @property {number} [min]
     * @property {string} [placeholder]
     *
     * @typedef {SettingBaseConfig<HTMLInputElement> & _SettingNumberConfig} SettingNumberConfig
     */

    /**
     * @typedef _SettingHtmlConfig
     * @property {'html'} type
     * @property {string} html
     *
     * @typedef {SettingBaseConfig & _SettingHtmlConfig} SettingHtmlConfig
     */

    /**
     * @typedef {SettingCheckboxConfig | SettingNumberConfig | SettingHtmlConfig} SettingItemConfig
     */

    /**
     * @typedef SettingSectionConfig
     * @property {string} title
     * @property {string} [id]
     * @property {string} [tip]
     * @property {SettingItemConfig[]} items
     */

    /**
     * @typedef SettingTabConfig
     * @property {string} [id]
     * @property {SettingSectionConfig[]} sections
     */

    /**
     * @param {string} tip
     * @returns {Element}
     */
    function createSettingTipEl(tip) {
      const dummyEl = document.createElement('div');
      dummyEl.innerHTML = `
        <div class="qa-icon" style="display:inline-block;top:1px;">
          <img src="https://i2.bahamut.com.tw/anime/smallQAicon.svg">
        </div>
      `;

      const tipEl = /** @type {Element} */ (dummyEl.firstElementChild);
      tipEl.setAttribute('tip-content', tip);
      return tipEl;
    }

    /**
     * @param {SettingItemConfig} config
     * @returns {DocumentFragment}
     */
    function createSettingItemLabelEl(config) {
      const fragment = document.createDocumentFragment();
      if (('label' in config) && config.label) {
        const dummyEl = document.createElement('div');
        dummyEl.innerHTML = `
          <div class="ani-setting-label">
            <span class="ani-setting-label__mian"></span>
          </div>
        `;

        const labelEl = dummyEl.querySelector('.ani-setting-label');
        if (labelEl) {
          labelEl.textContent = config.label;
        }

        fragment.append(...dummyEl.childNodes);

        if (config.labelTip) {
          fragment.append(createSettingTipEl(config.labelTip));
        }
      }
      return fragment;
    }

    /**
     * @param {SettingItemConfig} config
     * @returns {SettingComponent}
     */
    function createSettingItemComp(config) {
      if (config.type === 'checkbox') {
        const dummyEl = document.createElement('div');
        dummyEl.innerHTML = `
          <div class="ani-setting-item ani-flex">
            <div class="ani-setting-value ani-set-flex-right">
              <div class="ani-checkbox">
                <label class="ani-checkbox__label">
                <input type="checkbox" name="ani-checkbox">
                  <div class="ani-checkbox__button"></div>
                </label>
              </div>
            </div>
          </div>
        `;

        const itemEl = /** @type {HTMLDivElement} */ (dummyEl.firstElementChild);
        itemEl.prepend(createSettingItemLabelEl(config));

        const inputEl = itemEl.querySelector('input');
        if (inputEl) {
          if (config.id) {
            inputEl.id = config.id;
          }
          inputEl.checked = config.value ?? false;
        }

        return {
          ...createSettingComponentAttrs(config, inputEl),
          el: itemEl,
        };
      } else if (config.type === 'number') {
        const dummyEl = document.createElement('div');
        dummyEl.innerHTML = `
          <div class="ani-setting-item ani-flex">
            <div class="ani-setting-value ani-set-flex-right">
              <input type="number" class="ani-input" style="margin:0">
            </div>
          </div>
        `;

        const itemEl = /** @type {HTMLDivElement} */ (dummyEl.firstElementChild);
        itemEl.prepend(createSettingItemLabelEl(config));

        const inputEl = dummyEl.querySelector('input');
        if (inputEl) {
          if (config.id) {
            inputEl.id = config.id;
          }
          inputEl.value = config.value !== undefined ? String(config.value) : '';
          if (config.max !== undefined) {
            inputEl.max = String(config.max);
          }
          if (config.min !== undefined) {
            inputEl.min = String(config.min);
          }
          if (config.placeholder !== undefined) {
            inputEl.placeholder = config.placeholder;
          }
        }

        return {
          ...createSettingComponentAttrs(config, inputEl),
          el: itemEl,
        };
      } else if (config.type === 'html') {
        const dummyEl = document.createElement('div');
        dummyEl.innerHTML = config.html;
        const itemEl = dummyEl.firstElementChild ?? dummyEl;
        return {
          ...createSettingComponentAttrs(config, itemEl),
          el: itemEl,
        };
      } else {
        throw new Error(`Unknown setting item: ${config}`);
      }
    }

    /**
     * @param {SettingSectionConfig} config
     * @returns {SettingComponent}
     */
    function createSettingSectionComp(config) {
      const { title, id, tip, items } = config;

      const sectionEl = document.createElement('div');
      sectionEl.classList.add('ani-setting-section');

      const titleEl = document.createElement('h4');
      titleEl.classList.add('ani-setting-title');
      titleEl.textContent = title;

      if (id) {
        sectionEl.id = id;
      }
      if (tip) {
        const tipEl = createSettingTipEl(tip);
        tipEl.style.marginLeft = '8px';
        titleEl.appendChild(tipEl);
      }

      sectionEl.appendChild(titleEl);

      const itemComponents = items.map((item) => createSettingItemComp(item));
      for (const { el } of itemComponents) {
        sectionEl.append(el);
      }

      return {
        ...mergeSettingComponentAttrs(itemComponents),
        el: sectionEl,
      };
    }

    /**
     * @param {SettingTabConfig} config
     * @returns {SettingComponent}
     */
    function createSettingTabComp(config) {
      const tabEl = document.createElement('div');
      if (config.id) {
        tabEl.id = config.id;
      }
      tabEl.classList.add('ani-tab-content__item');

      const sectionComponents = config.sections.map((section) => createSettingSectionComp(section));
      for (const { el } of sectionComponents) {
        tabEl.append(el);
      }

      return {
        ...mergeSettingComponentAttrs(sectionComponents),
        el: tabEl,
      };
    }

    /**
     * @template {Element} T
     * @param {SettingBaseConfig<T>} config
     * @param {T | null} el
     * @returns {Omit<SettingComponent, 'el'>}
     */
    function createSettingComponentAttrs(config, el) {
      return {
        onMounted: (config.onMounted && el) ? config.onMounted.bind(null, el) : undefined,
        onSettings: (config.onSettings && el) ? config.onSettings.bind(null, el) : undefined,
        shortcuts: (config.shortcuts && el) ?
          Object.fromEntries(
            Object.entries(config.shortcuts).map(([key, handler]) => {
              return [key, [handler.bind(null, el)]];
            }),
          ) :
          undefined,
      };
    }

    /**
     * @param {SettingComponent[]} components
     * @returns {Omit<SettingComponent, 'el'>}
     */
    function mergeSettingComponentAttrs(components) {
      return {
        onMounted() {
          for (const { onMounted } of components) {
            onMounted?.();
          }
        },
        onSettings(settings) {
          for (const { onSettings } of components) {
            onSettings?.(settings);
          }
        },
        shortcuts: components
          .flatMap(({ shortcuts }) => Object.entries(shortcuts ?? {}))
          .reduce((acc, [key, handlers]) => {
            if (!acc[key]) {
              acc[key] = [];
            }
            acc[key].push(...handlers);
            return acc;
          }, /** @type {NonNullable<SettingComponent['shortcuts']>} */ ({})),
      };
    }

    /**
     * @returns {Record<string, Array<() => void>>}
     */
    function getLocalShortcuts() {
      return tabContentComponent?.shortcuts ?? {};
    }

    /**
     * @param {Partial<Settings>} settings
     */
    function applySettings(settings) {
      if (settings.timelineActions) {
        timelineActions = settings.timelineActions;
      }
      tabContentComponent?.onSettings?.(settings);
    }

    attachCss();
    attachTabUi();
    attachTabContentUi();

    return {
      applySettings,
      getLocalShortcuts,
    };
  }

  /**
   * @returns {Promise<VjsPlayerElement>}
   */
  async function waitVjsPlayerElementInit() {
    /**
     * @returns {VjsPlayerElement | null}
     */
    function queryVjsPlayerElement() {
      return document.querySelector('.video-js');
    }

    /**
     * @param {VjsPlayerElement} vjsPlyer
     * @returns {boolean}
     */
    function checkVjsPlayerElementReady(vjsPlyer) {
      return !!vjsPlyer.querySelector('.stop');
    }

    let vjsPlyer = queryVjsPlayerElement();
    if (vjsPlyer && checkVjsPlayerElementReady(vjsPlyer)) {
      return vjsPlyer;
    }

    /** @type {MutationObserver | undefined} */
    let mutationObserver;
    return new Promise((resolve) => {
      mutationObserver = new MutationObserver(async () => {
        if (!vjsPlyer) {
          vjsPlyer = queryVjsPlayerElement();
        }
        if (vjsPlyer && checkVjsPlayerElementReady(vjsPlyer)) {
          resolve(vjsPlyer);
        }
      });
      mutationObserver.observe(document.body, {
        childList: true,
        subtree: true,
      });
    }).finally(() => {
      mutationObserver?.disconnect();
    });
  }

  async function main() {
    const settings = await loadSettings();
    const vjsPlayerElement = await waitVjsPlayerElementInit();

    const contentRatingStore = useContentRating(vjsPlayerElement);
    const nextEpisodeStore = useNextEpisode(vjsPlayerElement);
    const shortcutsStore = useShortcuts(vjsPlayerElement);
    const timelineActionsStore = useTimelineActions(vjsPlayerElement);
    const settingUiStore = useSettingUi(vjsPlayerElement, (settings) => {
      saveSettings(settings);
      applySettings(settings);
    });

    shortcutsStore.addLocalShortcuts(settingUiStore.getLocalShortcuts());

    /**
     * @param {Partial<Settings>} settings
     */
    function applySettings(settings) {
      if (settings.autoAgreeContentRating !== undefined) {
        contentRatingStore.enableAutoAgreeContentRating(settings.autoAgreeContentRating);
      }
      if (settings.autoPlayNextEpisode !== undefined) {
        nextEpisodeStore.enableAutoPlayNextEpisode(settings.autoPlayNextEpisode);
      }
      if (settings.autoPlayNextEpisodeDelay !== undefined) {
        nextEpisodeStore.setAutoPlayNextEpisodeDelay(settings.autoPlayNextEpisodeDelay);
      }
      if (settings.shortcutActions !== undefined) {
        shortcutsStore.setCustomShortcuts(settings.shortcutActions);
      }
      if (settings.timelineActions !== undefined) {
        timelineActionsStore.setTimelineActions(settings.timelineActions);
      }
      settingUiStore.applySettings(settings);
    }

    applySettings(settings);
  }

  main();
})();