Improved AWBW Music Player

An improved version of the comprehensive audio player that attempts to recreate the cart experience with more sound effects, more music, and more customizability.

目前为 2025-01-12 提交的版本,查看 最新版本

// ==UserScript==
// @name        Improved AWBW Music Player
// @description An improved version of the comprehensive audio player that attempts to recreate the cart experience with more sound effects, more music, and more customizability.
// @namespace   https://awbw.amarriner.com/
// @author      DeveloperJose, _twiggy
// @match       https://awbw.amarriner.com/game.php*
// @match       https://awbw.amarriner.com/moveplanner.php*
// @match       https://awbw.amarriner.com/*editmap*
// @match       https://awbw.amarriner.com/yourgames.php*
// @match       https://awbw.amarriner.com/yourturn.php*
// @match       https://awbw.amarriner.com/live_queue.php*
// @icon        https://developerjose.netlify.app/img/music-player-icon.png
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/howler.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/spark-md5.min.js
// @require     https://cdn.jsdelivr.net/npm/[email protected]/build/can-autoplay.min.js
// @version     4.3.0
// @supportURL  https://github.com/DeveloperJose/JS-AWBW-User-Scripts/issues
// @license     MIT
// @unwrap
// @grant       none
// ==/UserScript==

var awbw_music_player = (function (exports, canAutoplay, Howl, SparkMD5) {
  "use strict";

  function styleInject(css, ref) {
    if (ref === void 0) ref = {};
    var insertAt = ref.insertAt;

    if (!css || typeof document === "undefined") {
      return;
    }

    var head = document.head || document.getElementsByTagName("head")[0];
    var style = document.createElement("style");
    style.type = "text/css";

    if (insertAt === "top") {
      if (head.firstChild) {
        head.insertBefore(style, head.firstChild);
      } else {
        head.appendChild(style);
      }
    } else {
      head.appendChild(style);
    }

    if (style.styleSheet) {
      style.styleSheet.cssText = css;
    } else {
      style.appendChild(document.createTextNode(css));
    }
  }

  var css_248z$1 =
    '/* This file is used to style the music player settings */\n\n.cls-settings-menu {\n  display: none;\n  /* display: flex; */\n  top: 40px;\n  flex-direction: column;\n  width: 850px;\n  border: black 1px solid;\n}\n\n.cls-settings-menu label {\n  background-color: white;\n  font-size: 12px;\n}\n\n.cls-settings-menu .cls-group-box > label {\n  width: 100%;\n  font-size: 13px;\n  background-color: #d6e0ed;\n  padding-top: 2px;\n  padding-bottom: 2px;\n}\n\n.cls-settings-menu .cls-vertical-box {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-evenly;\n  align-items: center;\n  padding-left: 5px;\n  padding-right: 5px;\n  padding-top: 1px;\n  padding-bottom: 1px;\n  height: 100%;\n  width: 100%;\n  position: relative;\n}\n\n.cls-settings-menu .cls-horizontal-box {\n  display: flex;\n  flex-direction: row;\n  justify-content: space-evenly;\n  align-items: center;\n  padding-left: 5px;\n  padding-right: 5px;\n  padding-top: 1px;\n  padding-bottom: 1px;\n  height: 100%;\n  width: 100%;\n  position: relative;\n}\n\n/* Puts the checkbox next to the label */\n.cls-settings-menu .cls-vertical-box[id$="extra-options"] {\n  align-items: center;\n  align-self: center;\n}\n\n.cls-settings-menu .cls-vertical-box[id$="extra-options"] .cls-horizontal-box {\n  width: 100%;\n  justify-content: center;\n}\n\n.cls-settings-menu .cls-horizontal-box[id$="random-themes"],\n.cls-settings-menu .cls-horizontal-box[id$="soundtrack"] {\n  justify-content: center;\n}\n\n.cls-settings-menu-box {\n  display: flex;\n  flex-direction: column;\n  justify-content: space-evenly;\n  padding-left: 5px;\n  padding-right: 5px;\n  padding-top: 1px;\n  padding-bottom: 1px;\n  width: 100%;\n}\n\n.cls-settings-menu image {\n  vertical-align: middle;\n}\n\n.cls-settings-menu label[id$="version"] {\n  width: 100%;\n  font-size: 10px;\n  color: #888888;\n  background-color: #f0f0f0;\n}\n\n.cls-settings-menu .co_caret {\n  position: absolute;\n  top: 28px;\n  left: 25px;\n  border: none;\n  z-index: 110;\n}\n\n.cls-settings-menu .co_portrait {\n  border-color: #009966;\n  z-index: 100;\n  border: 2px solid;\n  vertical-align: middle;\n  align-self: center;\n}\n\n.cls-settings-menu input[type="range"][id$="themes-start-on-day"] {\n  --c: rgb(168, 73, 208); /* active color */\n}\n';
  styleInject(css_248z$1);

  var css_248z =
    '/* \n * CSS Custom Range Slider\n * https://www.sitepoint.com/css-custom-range-slider/ \n */\n\n.cls-settings-menu input[type="range"] {\n  --c: rgb(53 57 60); /* active color */\n  --l: 15px; /* line thickness*/\n  --h: 30px; /* thumb height */\n  --w: 15px; /* thumb width */\n\n  width: 100%;\n  height: var(--h); /* needed for Firefox*/\n  --_c: color-mix(in srgb, var(--c), #000 var(--p, 0%));\n  -webkit-appearance: none;\n  -moz-appearance: none;\n  appearance: none;\n  background: none;\n  cursor: pointer;\n  overflow: hidden;\n  display: inline-block;\n}\n.cls-settings-menu input:focus-visible,\n.cls-settings-menu input:hover {\n  --p: 25%;\n}\n\n/* chromium */\n.cls-settings-menu input[type="range" i]::-webkit-slider-thumb {\n  height: var(--h);\n  width: var(--w);\n  background: var(--_c);\n  border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 100vw;\n  -webkit-appearance: none;\n  appearance: none;\n  transition: 0.3s;\n}\n/* Firefox */\n.cls-settings-menu input[type="range"]::-moz-range-thumb {\n  height: var(--h);\n  width: var(--w);\n  background: var(--_c);\n  border-image: linear-gradient(90deg, var(--_c) 50%, #ababab 0) 0 1 / calc(50% - var(--l) / 2) 100vw/0 100vw;\n  -webkit-appearance: none;\n  appearance: none;\n  transition: 0.3s;\n}\n@supports not (color: color-mix(in srgb, red, red)) {\n  .cls-settings-menu input {\n    --_c: var(--c);\n  }\n}\n';
  styleInject(css_248z);

  /**
   * @file Constants, variables, and functions that come from analyzing the web pages of AWBW.
   *
   * querySelector()
   * . = class
   * # = id
   */
  /**
   * Are we in the map editor?
   */
  function isMapEditor() {
    return window.location.href.indexOf("editmap.php?") > -1;
  }
  function isMaintenance() {
    return document.querySelector("#server-maintenance-alert") !== null;
  }
  function isMovePlanner() {
    return window.location.href.indexOf("moveplanner.php") > -1;
  }
  function isYourGames() {
    return window.location.href.indexOf("yourgames.php") > -1 || window.location.href.indexOf("yourturn.php") > -1;
  }
  function isGamePageAndActive() {
    return window.location.href.indexOf("game.php") > -1 && !isMaintenance();
  }
  function isLiveQueue() {
    return window.location.href.indexOf("live_queue.php") > -1;
  }
  function getCoordsDiv() {
    return document.querySelector("#coords");
  }
  function getReplayControls() {
    return document.querySelector(".replay-controls");
  }
  function getReplayCloseBtn() {
    return document.querySelector(".replay-close");
  }
  function getReplayForwardBtn() {
    return document.querySelector(".replay-forward");
  }
  function getReplayForwardActionBtn() {
    return document.querySelector(".replay-forward-action");
  }
  function getReplayBackwardBtn() {
    return document.querySelector(".replay-backward");
  }
  function getReplayBackwardActionBtn() {
    return document.querySelector(".replay-backward-action");
  }
  function getReplayDaySelectorCheckBox() {
    return document.querySelector(".replay-day-selector");
  }
  function getConnectionErrorDiv() {
    return document.querySelector(".connection-error-msg");
  }
  function getLiveQueueSelectPopup() {
    return document.querySelector("#live-queue-select-popup");
  }
  function getLiveQueueBlockerPopup() {
    return document.querySelector(".live-queue-blocker-popup");
  }
  // ============================== Useful Page Utilities ==============================
  /**
   * Gets the HTML div element for the given building, if it exists.
   * @param buildingID - The ID of the building.
   * @returns - The HTML div element for the building, or null if it does not exist.
   */
  function getBuildingDiv(buildingID) {
    return document.querySelector(`.game-building[data-building-id='${buildingID}']`);
  }
  function getAllDamageSquares() {
    return Array.from(document.getElementsByClassName("dmg-square"));
  }
  /**
   * How much time in milliseconds to let pass between animation steps for {@link moveDivToOffset}.
   * The lower, the faster the "animation" will play.
   * @constant
   */
  const moveAnimationDelayMS = 5;
  /**
   * Animates the movement of a div element through moving it by a certain number of pixels in each direction at each step.
   * @param div - The div element to animate.
   * @param dx - Number of pixels to move left/right (column) at each step
   * @param dy - Number of pixels to move up/down (row) at each step
   * @param steps - Number of steps to take
   * @param followUpAnimations - Any additional animations to play after this one finishes.
   */
  function moveDivToOffset(div, dx, dy, steps, ...followUpAnimations) {
    if (steps <= 1) {
      if (!followUpAnimations || followUpAnimations.length === 0) return;
      const nextSet = followUpAnimations.shift()?.then;
      if (!nextSet) return;
      moveDivToOffset(div, nextSet[0], nextSet[1], nextSet[2], ...followUpAnimations);
      return;
    }
    window.setTimeout(() => moveDivToOffset(div, dx, dy, steps - 1, ...followUpAnimations), moveAnimationDelayMS);
    let left = parseFloat(div.style.left);
    let top = parseFloat(div.style.top);
    left += dx;
    top += dy;
    div.style.left = left + "px";
    div.style.top = top + "px";
  }
  /**
   * Adds an observer to the cursor coordinates so we can replicate the "updateCursor" function outside of game.php
   * @param onCursorMove - The function to call when the cursor moves.
   */
  function addUpdateCursorObserver(onCursorMove) {
    // We want to catch when div textContent is changed
    const coordsDiv = getCoordsDiv();
    if (!coordsDiv) return;
    const observer = new MutationObserver((mutationsList) => {
      for (const mutation of mutationsList) {
        if (mutation.type !== "childList") return;
        if (!mutation.target) return;
        if (!mutation.target.textContent) return;
        // (X, Y)
        let coordsText = mutation.target.textContent;
        // Remove parentheses and split by comma
        coordsText = coordsText.substring(1, coordsText.length - 1);
        const splitCoords = coordsText.split(",");
        const cursorX = Number(splitCoords[0]);
        const cursorY = Number(splitCoords[1]);
        onCursorMove(cursorX, cursorY);
      }
    });
    observer.observe(coordsDiv, { childList: true });
  }

  /**
   * @file Global variables exposed by Advance Wars By Web's JS code and other useful constants.
   */
  // ============================== Advance Wars Stuff ==============================
  /**
   * List of Orange Star COs, stored in a set for more efficient lookups.
   */
  const ORANGE_STAR_COs = new Set(["andy", "max", "sami", "nell", "hachi", "jake", "rachel"]);
  /**
   * List of Blue Moon COs, stored in a set for more efficient lookups.
   */
  const BLUE_MOON_COs = new Set(["olaf", "grit", "colin", "sasha"]);
  /**
   * List of Green Earth COs, stored in a set for more efficient lookups.
   */
  const GREEN_EARTH_COs = new Set(["eagle", "drake", "jess", "javier"]);
  /**
   * List of Yellow Comet COs, stored in a set for more efficient lookups.
   */
  const YELLOW_COMET_COs = new Set(["kanbei", "sonja", "sensei", "grimm"]);
  /**
   * List of Black Hole COs, stored in a set for more efficient lookups.
   * @constant
   */
  const BLACK_HOLE_COs = new Set(["flak", "lash", "adder", "hawke", "sturm", "jugger", "koal", "kindle", "vonbolt"]);
  /**
   * List of COs that are only available in Advance Wars 2, stored in a set for more efficient lookups.
   */
  const AW2_ONLY_COs = new Set(["hachi", "colin", "sensei", "jess", "flak", "adder", "lash", "hawke"]);
  /**
   * List of COs that are only available in Dual Strike, stored in a set for more efficient lookups.
   */
  const AW_DS_ONLY_COs = new Set(["jake", "rachel", "sasha", "javier", "grimm", "kindle", "jugger", "koal", "vonbolt"]);
  /**
   * List of all COs in the game.
   */
  function getAllCONames(properCase = false) {
    if (!properCase)
      return [...ORANGE_STAR_COs, ...BLUE_MOON_COs, ...GREEN_EARTH_COs, ...YELLOW_COMET_COs, ...BLACK_HOLE_COs];
    const allCOs = [...ORANGE_STAR_COs, ...BLUE_MOON_COs, ...GREEN_EARTH_COs, ...YELLOW_COMET_COs, ...BLACK_HOLE_COs];
    allCOs[allCOs.indexOf("vonbolt")] = "Von Bolt";
    return allCOs.map((co) => co[0].toUpperCase() + co.slice(1));
  }
  /**
   * Whether game animations are enabled or not.
   */
  function areAnimationsEnabled() {
    return typeof gameAnims !== "undefined" ? gameAnims : false;
  }
  /**
   * Determines if the given CO is an ally or a part of Black Hole.
   * @param coName - Name of the CO to check.
   * @returns - True if the given CO is part of Black Hole.
   */
  function isBlackHoleCO(coName) {
    // Convert to internal format just in case
    coName = coName.toLowerCase().replaceAll(" ", "");
    return BLACK_HOLE_COs.has(coName);
  }
  /**
   * Randomly selects a CO from the list of all COs.
   * @returns - The name of the randomly selected CO.
   */
  function getRandomCO(excludedCOs) {
    const COs = new Set(getAllCONames());
    for (const co of excludedCOs) COs.delete(co);
    // No COs available, play the map editor music
    if (COs.size === 0) return "map-editor";
    // Only one CO available, return it
    if (COs.size === 1) return [...COs][0];
    return [...COs][Math.floor(Math.random() * COs.size)];
  }

  /**
   * @file Constants, functions, and variables related to the game state in Advance Wars By Web.
   *  A lot of useful information came from game.js and the code at the bottom of each game page.
   */
  /**
   * Enum for the different states a CO Power can be in.
   * @enum {string}
   */
  var COPowerEnum;
  (function (COPowerEnum) {
    COPowerEnum["NoPower"] = "N";
    COPowerEnum["COPower"] = "Y";
    COPowerEnum["SuperCOPower"] = "S";
  })(COPowerEnum || (COPowerEnum = {}));
  /**
   * The amount of time between the silo launch animation and the hit animation in milliseconds.
   * Copied from game.js
   */
  const siloDelayMS = areAnimationsEnabled() ? 3000 : 0;
  /**
   * The amount of time between an attack animation starting and the attack finishing in milliseconds.
   * Copied from game.js
   */
  const attackDelayMS = areAnimationsEnabled() ? 1000 : 0;
  /**
   * Gets the username of the person logged in to the website.
   */
  function getMyUsername() {
    const profileMenu = document.querySelector("#profile-menu");
    if (!profileMenu) return null;
    const link = profileMenu.getElementsByClassName("dropdown-menu-link")[0];
    return link.href.split("username=")[1];
  }
  /**
   * The player ID for the person logged in to the website.
   * Singleton set and returned by {@link getMyID}
   */
  let myID = -1;
  /**
   * Gets the ID of the person logged in to the website.
   * @returns - The player ID of the person logged in to the website.
   */
  function getMyID() {
    if (!isGamePageAndActive()) return -1;
    if (myID < 0) {
      getAllPlayersInfo().forEach((entry) => {
        if (entry.users_username === getMyUsername()) {
          myID = entry.players_id;
        }
      });
    }
    return myID;
  }
  /**
   * Gets the player info data for the given user ID or null if the user ID is not part of the game.
   * @param pid - Player ID whose info we will get.
   * @returns - The info for that given player or null if such ID is not present in the game.
   */
  function getPlayerInfo(pid) {
    if (!isGamePageAndActive()) return null;
    return playersInfo[pid];
  }
  /**
   * Gets a list of all the player info data for all players in the current game.
   * @returns - List of player info data for all players in the current game.
   */
  function getAllPlayersInfo() {
    if (!isGamePageAndActive()) return [];
    return Object.values(playersInfo);
  }
  /**
   * Determines if the given player is a spectator based on their ID.
   * @param pid - Player ID who we want to check.
   * @returns True if the player is a spectator, false if they are playing in this game.
   */
  function isPlayerSpectator(pid) {
    if (!isGamePageAndActive()) return false;
    return !playerKeys.includes(pid);
  }
  /**
   * Checks if the given player is able to activate a regular CO Power.
   * @param pid - Player ID for whom we want to check.
   * @returns - True if the player can activate a regular CO Power.
   */
  function canPlayerActivateCOPower(pid) {
    if (!isGamePageAndActive()) return false;
    const info = getPlayerInfo(pid);
    if (!info) return false;
    return info.players_co_power >= info.players_co_max_power;
  }
  /**
   * Checks if the given player is able to activate a Super CO Power.
   * @param pid - Player ID for whom we want to check.
   * @returns - True if the player can activate a Super CO Power.
   */
  function canPlayerActivateSuperCOPower(pid) {
    if (!isGamePageAndActive()) return false;
    const info = getPlayerInfo(pid);
    if (!info) return false;
    return info.players_co_power >= info.players_co_max_spower;
  }
  /**
   * Gets the internal info object for the given building.
   * @param x - X coordinate of the building.
   * @param y - Y coordinate of the building.
   * @returns - The info for that building at its current state.
   */
  function getBuildingInfo(x, y) {
    if (!isGamePageAndActive()) return null;
    return buildingsInfo[x][y];
  }
  /**
   * Checks if we are currently in replay mode.
   * @returns - True if we are in replay mode.
   */
  function isReplayActive() {
    if (!isGamePageAndActive()) return false;
    // Check if replay mode is open by checking if the replay section is set to display
    const replayControls = getReplayControls();
    const replayOpen = replayControls.style.display !== "none";
    return replayOpen && Object.keys(replay).length > 0;
  }
  /**
   * Checks if the game has ended.
   * @returns - True if the game has ended.
   */
  function hasGameEnded() {
    if (!isGamePageAndActive()) return false;
    // Count how many players are still in the game
    const numberOfRemainingPlayers = Object.values(playersInfo).filter(
      (info) => info.players_eliminated === "N",
    ).length;
    return numberOfRemainingPlayers === 1;
  }
  /**
   * Gets the current day in the game, also works properly in replay mode.
   * In the map editor, we consider it to be day 1.
   * @returns - The current day in the game.
   */
  function getCurrentGameDay() {
    if (!isGamePageAndActive()) return 1;
    if (!isReplayActive()) return gameDay;
    const replayData = Object.values(replay);
    if (replayData.length === 0) return gameDay;
    const lastData = replayData[replayData.length - 1];
    if (typeof lastData === "undefined") return gameDay;
    if (typeof lastData.day === "undefined") return gameDay;
    return lastData.day;
  }
  /**
   * Useful variables related to the player currently playing this turn.
   */
  class currentPlayer {
    /**
     * Get the internal info object containing the state of the current player.
     */
    static get info() {
      if (!isGamePageAndActive()) return null;
      if (typeof currentTurn === "undefined") return null;
      return getPlayerInfo(currentTurn);
    }
    /**
     * Determine whether a CO Power or Super CO Power is activated for the current player.
     * @returns - True if a regular CO power or a Super CO Power is activated.
     */
    static get isPowerActivated() {
      if (!isGamePageAndActive()) return false;
      return this?.coPowerState !== COPowerEnum.NoPower;
    }
    /**
     * Gets state of the CO Power for the current player represented as a single letter.
     * @returns - The state of the CO Power for the current player.
     */
    static get coPowerState() {
      if (!isGamePageAndActive()) return COPowerEnum.NoPower;
      return this.info?.players_co_power_on;
    }
    /**
     * Determine if the current player has been eliminated from the game.
     * @returns - True if the current player has been eliminated.
     */
    static get isEliminated() {
      if (!isGamePageAndActive()) return false;
      return this.info?.players_eliminated === "Y";
    }
    /**
     * Gets the name of the CO for the current player.
     * If the game has ended, it will return "victory" or "defeat".
     * If we are in the map editor, it will return "map-editor".
     * @returns - The name of the CO for the current player.
     */
    static get coName() {
      if (isMapEditor()) return "map-editor";
      if (isMaintenance()) return "maintenance";
      if (!isGamePageAndActive()) return null;
      const myID = getMyID();
      const myInfo = getPlayerInfo(myID);
      const myLoss = myInfo?.players_eliminated === "Y";
      // Play victory/defeat themes after the game ends for everyone
      if (hasGameEnded()) {
        if (!isReplayActive()) return "co-select";
        if (isPlayerSpectator(myID)) return "victory";
        return myLoss ? "defeat" : "victory";
      }
      // Check if we are eliminated even if the game has not ended
      if (myLoss) return "defeat";
      return this.info?.co_name;
    }
  }
  /**
   * Determine who all the COs of the game are and return a list of their names.
   * @returns - List with the names of each CO in the game.
   */
  function getAllPlayingCONames() {
    if (isMapEditor()) return new Set(["map-editor"]);
    if (!isGamePageAndActive()) return new Set();
    const allPlayers = new Set(getAllPlayersInfo().map((info) => info.co_name));
    const allTagPlayers = getAllTagCONames();
    return new Set([...allPlayers, ...allTagPlayers]);
  }
  /**
   * Checks if the game is a tag game with 2 COs per team.
   * @returns - True if the game is a tag game.
   */
  function isTagGame() {
    if (!isGamePageAndActive()) return false;
    return typeof tagsInfo !== "undefined" && tagsInfo;
  }
  /**
   * If the game is a tag game, get the names of all secondary COs that are part of the tags.
   * @returns - Set with the names of each secondary CO in the tag.
   */
  function getAllTagCONames() {
    if (!isGamePageAndActive() || !isTagGame()) return new Set();
    return new Set(Object.values(tagsInfo).map((tag) => tag.co_name));
  }
  /**
   * Gets the internal info object for the given unit.
   * @param unitId - ID of the unit for whom we want to get the current info state.
   * @returns - The info for that unit at its current state.
   */
  function getUnitInfo(unitId) {
    if (!isGamePageAndActive()) return null;
    return unitsInfo[unitId];
  }
  /**
   * Gets the name of the given unit or null if the given unit is invalid.
   * @param unitId - ID of the unit for whom we want to get the name.
   * @returns - Name of the unit.
   */
  function getUnitName(unitId) {
    if (!isGamePageAndActive()) return null;
    return getUnitInfo(unitId)?.units_name;
  }
  /**
   * Try to get the unit info for the unit at the given coordinates, if any.
   * @param x - X coordinate to get the unit info from.
   * @param y - Y coordinate to get the unit info from.
   * @returns - The info for the unit at the given coordinates or null if there is no unit there.
   */
  function getUnitInfoFromCoords(x, y) {
    if (!isGamePageAndActive()) return null;
    return Object.values(unitsInfo)
      .filter((info) => info.units_x == x && info.units_y == y)
      .pop();
  }
  /**
   * Checks if the given unit is a valid unit.
   * A unit is valid when we can find its info in the current game state.
   * @param unitId - ID of the unit we want to check.
   * @returns - True if the given unit is valid.
   */
  function isValidUnit(unitId) {
    if (!isGamePageAndActive()) return false;
    return unitId !== undefined && unitsInfo[unitId] !== undefined;
  }
  /**
   * Checks if the given unit has moved this turn.
   * @param unitId - ID of the unit we want to check.
   * @returns - True if the unit is valid and it has moved this turn.
   */
  function hasUnitMovedThisTurn(unitId) {
    if (!isGamePageAndActive()) return false;
    return isValidUnit(unitId) && getUnitInfo(unitId)?.units_moved === 1;
  }
  function addConnectionErrorObserver(onConnectionError) {
    const connectionErrorDiv = getConnectionErrorDiv();
    if (!connectionErrorDiv) return;
    const observer = new MutationObserver((mutationsList) => {
      for (const mutation of mutationsList) {
        if (mutation.type !== "childList") return;
        if (!mutation.target) return;
        if (!mutation.target.textContent) return;
        const closeMsg = mutation.target.textContent;
        onConnectionError(closeMsg);
      }
    });
    observer.observe(connectionErrorDiv, { childList: true });
  }

  /**
   * @file Utility functions for the music player that don't fit anywhere else specifically.
   */
  /**
   * Logs a message to the console with the prefix "[AWBW Improved Music Player]"
   * @param message - The message to log
   * @param args - Additional arguments to log
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function log(message, ...args) {
    console.log("[AWBW Improved Music Player]", message, ...args);
  }
  /**
   * Logs a warning message to the console with the prefix "[AWBW Improved Music Player]"
   * @param message - The message to log
   * @param args - Additional arguments to log
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function logError(message, ...args) {
    console.error("[AWBW Improved Music Player]", message, ...args);
  }
  /**
   * Logs a debug message to the console with the prefix "[AWBW Improved Music Player]"
   * @param message - The message to log
   * @param args - Additional arguments to log
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function logDebug(message, ...args) {
    console.debug("[AWBW Improved Music Player]", message, ...args);
  }

  /**
   * @file This file contains the state of the music player settings and the saving/loading functionality, no UI functionality.
   * Note: For Enums in pure JS we just have objects where the keys and values match, it's the easiest solution
   */
  /**
   * Enum that represents which game we want the music player to use for its music.
   * @enum {string}
   */
  var GameType;
  (function (GameType) {
    GameType["AW1"] = "AW1";
    GameType["AW2"] = "AW2";
    GameType["RBC"] = "RBC";
    GameType["DS"] = "DS";
  })(GameType || (GameType = {}));
  /**
   * Enum that represents music theme types like regular or power.
   * @enum {string}
   */
  var ThemeType;
  (function (ThemeType) {
    ThemeType["REGULAR"] = "REGULAR";
    ThemeType["CO_POWER"] = "CO_POWER";
    ThemeType["SUPER_CO_POWER"] = "SUPER_CO_POWER";
  })(ThemeType || (ThemeType = {}));
  /**
   * Enum that represents different options for random themes.
   * @enum {string}
   */
  var RandomThemeType;
  (function (RandomThemeType) {
    RandomThemeType["NONE"] = "NONE";
    RandomThemeType["ALL_THEMES"] = "ALL_THEMES";
    RandomThemeType["CURRENT_SOUNDTRACK"] = "CURRENT_SOUNDTRACK";
  })(RandomThemeType || (RandomThemeType = {}));
  /**
   * Gets the theme type enum corresponding to the CO Power state for the current CO.
   * @returns - The SettingsThemeType enum for the current CO Power state.
   */
  function getCurrentThemeType() {
    const currentPowerState = currentPlayer?.coPowerState;
    if (currentPowerState === "Y") return ThemeType.CO_POWER;
    if (currentPowerState === "S") return ThemeType.SUPER_CO_POWER;
    return ThemeType.REGULAR;
  }
  /**
   * Gets a random game type from the SettingsGameType enum.
   * @returns - A random game type from the SettingsGameType enum.
   */
  function getRandomGameType() {
    return Object.values(GameType)[Math.floor(Math.random() * Object.keys(GameType).length)];
  }
  /**
   * String used as the key for storing settings in LocalStorage
   * @constant
   */
  const STORAGE_KEY = "musicPlayerSettings";
  /**
   * List of listener functions that will be called anytime settings are changed.
   */
  const onSettingsChangeListeners = [];
  /**
   * Adds a new listener function that will be called whenever a setting changes.
   * @param fn - The function to call when a setting changes.
   */
  function addSettingsChangeListener(fn) {
    onSettingsChangeListeners.push(fn);
  }
  /**
   * Enum that represents the keys for the music player settings.
   * @enum {number}
   */
  var SettingsKey;
  (function (SettingsKey) {
    SettingsKey[(SettingsKey["IS_PLAYING"] = 0)] = "IS_PLAYING";
    SettingsKey[(SettingsKey["VOLUME"] = 1)] = "VOLUME";
    SettingsKey[(SettingsKey["SFX_VOLUME"] = 2)] = "SFX_VOLUME";
    SettingsKey[(SettingsKey["UI_VOLUME"] = 3)] = "UI_VOLUME";
    SettingsKey[(SettingsKey["GAME_TYPE"] = 4)] = "GAME_TYPE";
    SettingsKey[(SettingsKey["ALTERNATE_THEMES"] = 5)] = "ALTERNATE_THEMES";
    SettingsKey[(SettingsKey["ALTERNATE_THEME_DAY"] = 6)] = "ALTERNATE_THEME_DAY";
    SettingsKey[(SettingsKey["RANDOM_THEMES_TYPE"] = 7)] = "RANDOM_THEMES_TYPE";
    SettingsKey[(SettingsKey["CAPTURE_PROGRESS_SFX"] = 8)] = "CAPTURE_PROGRESS_SFX";
    SettingsKey[(SettingsKey["PIPE_SEAM_SFX"] = 9)] = "PIPE_SEAM_SFX";
    SettingsKey[(SettingsKey["OVERRIDE_LIST"] = 10)] = "OVERRIDE_LIST";
    SettingsKey[(SettingsKey["RESTART_THEMES"] = 11)] = "RESTART_THEMES";
    SettingsKey[(SettingsKey["AUTOPLAY_ON_OTHER_PAGES"] = 12)] = "AUTOPLAY_ON_OTHER_PAGES";
    SettingsKey[(SettingsKey["EXCLUDED_RANDOM_THEMES"] = 13)] = "EXCLUDED_RANDOM_THEMES";
    // Non-user configurable settings
    SettingsKey[(SettingsKey["THEME_TYPE"] = 14)] = "THEME_TYPE";
    SettingsKey[(SettingsKey["CURRENT_RANDOM_CO"] = 15)] = "CURRENT_RANDOM_CO";
    // Special keys that don't match specific variables
    SettingsKey[(SettingsKey["ALL"] = 16)] = "ALL";
    SettingsKey[(SettingsKey["ADD_OVERRIDE"] = 17)] = "ADD_OVERRIDE";
    SettingsKey[(SettingsKey["REMOVE_OVERRIDE"] = 18)] = "REMOVE_OVERRIDE";
    SettingsKey[(SettingsKey["ADD_EXCLUDED"] = 19)] = "ADD_EXCLUDED";
    SettingsKey[(SettingsKey["REMOVE_EXCLUDED"] = 20)] = "REMOVE_EXCLUDED";
  })(SettingsKey || (SettingsKey = {}));
  /**
   * The music player settings' current internal state.
   * DO NOT EDIT __ prefix variables, use the properties!
   */
  class musicSettings {
    // User configurable settings
    static __isPlaying = false;
    static __volume = 0.5;
    static __sfxVolume = 0.5;
    static __uiVolume = 0.5;
    static __gameType = GameType.DS;
    static __alternateThemes = true;
    static __alternateThemeDay = 15;
    static __randomThemesType = RandomThemeType.NONE;
    static __captureProgressSFX = true;
    static __pipeSeamSFX = true;
    static __overrideList = new Map();
    static __restartThemes = false;
    static __autoplayOnOtherPages = true;
    static __excludedRandomThemes = new Set();
    // Non-user configurable settings
    static __themeType = ThemeType.REGULAR;
    static __currentRandomCO = "";
    static __currentRandomGameType = GameType.DS;
    static __isLoaded = false;
    static toJSON() {
      return JSON.stringify({
        isPlaying: this.__isPlaying,
        volume: this.__volume,
        sfxVolume: this.__sfxVolume,
        uiVolume: this.__uiVolume,
        gameType: this.__gameType,
        alternateThemes: this.__alternateThemes,
        alternateThemeDay: this.__alternateThemeDay,
        randomThemesType: this.__randomThemesType,
        captureProgressSFX: this.__captureProgressSFX,
        pipeSeamSFX: this.__pipeSeamSFX,
        overrideList: Array.from(this.__overrideList.entries()),
        restartThemes: this.__restartThemes,
        autoplayOnOtherPages: this.__autoplayOnOtherPages,
        excludedRandomThemes: Array.from(this.__excludedRandomThemes),
      });
    }
    static fromJSON(json) {
      // Only keep and set settings that are in the current version of musicPlayerSettings
      const savedSettings = JSON.parse(json);
      for (let key in this) {
        key = key.substring(2); // Remove the __ prefix
        if (Object.hasOwn(savedSettings, key)) {
          // Special case for the overrideList, it's a Map
          if (key === "overrideList") {
            this.__overrideList = new Map(savedSettings[key]);
            continue;
          }
          if (key === "excludedRandomThemes") {
            this.__excludedRandomThemes = new Set(savedSettings[key]);
            continue;
          }
          // For all other settings, just set them with the setter function
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          this[key] = savedSettings[key];
          // debug("Loading", key, "as", savedSettings[key]);
        }
      }
      this.__isLoaded = true;
    }
    static set isPlaying(val) {
      if (this.__isPlaying === val) return;
      this.__isPlaying = val;
      this.onSettingChangeEvent(SettingsKey.IS_PLAYING);
    }
    static get isPlaying() {
      return this.__isPlaying;
    }
    static set volume(val) {
      if (this.__volume === val) return;
      this.__volume = val;
      this.onSettingChangeEvent(SettingsKey.VOLUME);
    }
    static get volume() {
      return this.__volume;
    }
    static set sfxVolume(val) {
      if (this.__sfxVolume === val) return;
      this.__sfxVolume = val;
      this.onSettingChangeEvent(SettingsKey.SFX_VOLUME);
    }
    static get sfxVolume() {
      return this.__sfxVolume;
    }
    static set uiVolume(val) {
      if (this.__uiVolume === val) return;
      this.__uiVolume = val;
      this.onSettingChangeEvent(SettingsKey.UI_VOLUME);
    }
    static get uiVolume() {
      return this.__uiVolume;
    }
    static set gameType(val) {
      if (this.__gameType === val) return;
      this.__gameType = val;
      // The user wants this game type, so override whatever random game type we had before
      this.__currentRandomGameType = val;
      this.onSettingChangeEvent(SettingsKey.GAME_TYPE);
    }
    static get gameType() {
      return this.__gameType;
    }
    static set alternateThemes(val) {
      if (this.__alternateThemes === val) return;
      this.__alternateThemes = val;
      this.onSettingChangeEvent(SettingsKey.ALTERNATE_THEMES);
    }
    static get alternateThemes() {
      return this.__alternateThemes;
    }
    static set alternateThemeDay(val) {
      if (this.__alternateThemeDay === val) return;
      this.__alternateThemeDay = val;
      this.onSettingChangeEvent(SettingsKey.ALTERNATE_THEME_DAY);
    }
    static get alternateThemeDay() {
      return this.__alternateThemeDay;
    }
    static set captureProgressSFX(val) {
      // if (this.__captureProgressSFX === val) return;
      this.__captureProgressSFX = val;
      this.onSettingChangeEvent(SettingsKey.CAPTURE_PROGRESS_SFX);
    }
    static get captureProgressSFX() {
      return this.__captureProgressSFX;
    }
    static set pipeSeamSFX(val) {
      // if (this.__pipeSeamSFX === val) return;
      this.__pipeSeamSFX = val;
      this.onSettingChangeEvent(SettingsKey.PIPE_SEAM_SFX);
    }
    static get pipeSeamSFX() {
      return this.__pipeSeamSFX;
    }
    static set overrideList(val) {
      this.__overrideList = new Map([...val.entries()].sort());
      this.onSettingChangeEvent(SettingsKey.OVERRIDE_LIST);
    }
    static get overrideList() {
      return this.__overrideList;
    }
    static addOverride(coName, gameType) {
      this.__overrideList.set(coName, gameType);
      this.__overrideList = new Map([...this.__overrideList.entries()].sort());
      this.onSettingChangeEvent(SettingsKey.ADD_OVERRIDE);
    }
    static removeOverride(coName) {
      this.__overrideList.delete(coName);
      this.__overrideList = new Map([...this.__overrideList.entries()].sort());
      this.onSettingChangeEvent(SettingsKey.REMOVE_OVERRIDE);
    }
    static getOverride(coName) {
      return this.__overrideList.get(coName);
    }
    static get restartThemes() {
      return this.__restartThemes;
    }
    static set restartThemes(val) {
      if (this.__restartThemes === val) return;
      this.__restartThemes = val;
      this.onSettingChangeEvent(SettingsKey.RESTART_THEMES);
    }
    static get autoplayOnOtherPages() {
      return this.__autoplayOnOtherPages;
    }
    static set autoplayOnOtherPages(val) {
      if (this.__autoplayOnOtherPages === val) return;
      this.__autoplayOnOtherPages = val;
      this.onSettingChangeEvent(SettingsKey.AUTOPLAY_ON_OTHER_PAGES);
    }
    static get excludedRandomThemes() {
      return this.__excludedRandomThemes;
    }
    static set excludedRandomThemes(val) {
      this.__excludedRandomThemes = val;
      this.onSettingChangeEvent(SettingsKey.EXCLUDED_RANDOM_THEMES);
    }
    static addExcludedRandomTheme(theme) {
      this.__excludedRandomThemes.add(theme);
      this.onSettingChangeEvent(SettingsKey.ADD_EXCLUDED);
    }
    static removeExcludedRandomTheme(theme) {
      this.__excludedRandomThemes.delete(theme);
      this.onSettingChangeEvent(SettingsKey.REMOVE_EXCLUDED);
    }
    // ************* Non-user configurable settings from here on
    static set themeType(val) {
      if (this.__themeType === val) return;
      this.__themeType = val;
      this.onSettingChangeEvent(SettingsKey.THEME_TYPE);
    }
    static get themeType() {
      return this.__themeType;
    }
    static set randomThemesType(val) {
      if (this.__randomThemesType === val) return;
      this.__randomThemesType = val;
      this.onSettingChangeEvent(SettingsKey.RANDOM_THEMES_TYPE);
    }
    static get randomThemesType() {
      return this.__randomThemesType;
    }
    static get currentRandomCO() {
      if (!this.__currentRandomCO || this.__currentRandomCO == "") this.randomizeCO();
      return this.__currentRandomCO;
    }
    static randomizeCO() {
      const excludedCOs = new Set([...this.__excludedRandomThemes, this.__currentRandomCO]);
      this.__currentRandomCO = getRandomCO(excludedCOs);
      this.__currentRandomGameType = getRandomGameType();
      this.onSettingChangeEvent(SettingsKey.CURRENT_RANDOM_CO);
    }
    static onSettingChangeEvent(key) {
      onSettingsChangeListeners.forEach((fn) => fn(key, !this.__isLoaded));
    }
    static get currentRandomGameType() {
      return this.__currentRandomGameType;
    }
  }
  /**
   * Loads the music player settings stored in the local storage.
   */
  function loadSettingsFromLocalStorage() {
    let storageData = localStorage.getItem(STORAGE_KEY);
    // Store defaults if nothing or undefined is stored
    if (!storageData || storageData === "undefined") {
      log("No saved settings found, storing defaults");
      storageData = updateSettingsInLocalStorage();
    }
    musicSettings.fromJSON(storageData);
    // Tell everyone we just loaded the settings
    onSettingsChangeListeners.forEach((fn) => fn(SettingsKey.ALL, true));
    logDebug("Settings loaded from storage:", storageData);
  }
  function allowSettingsToBeSaved() {
    // From now on, any setting changes will be saved and any listeners will be called
    addSettingsChangeListener(onSettingsChange$2);
  }
  function onSettingsChange$2(key, _isFirstLoad) {
    // We can't save the non-configurable settings
    if (key === SettingsKey.THEME_TYPE || key === SettingsKey.CURRENT_RANDOM_CO) return "";
    // Save all settings otherwise
    updateSettingsInLocalStorage();
  }
  /**
   * Saves the current music player settings in the local storage.
   */
  function updateSettingsInLocalStorage() {
    const jsonSettings = musicSettings.toJSON();
    localStorage.setItem(STORAGE_KEY, jsonSettings);
    logDebug("Saving settings...", jsonSettings);
    return jsonSettings;
  }

  /**
   * @file All external resources used by this userscript like URLs and convenience functions for those URLs.
   */
  /**
   * Base URL where all the files needed for this script are located.
   * @constant {string}
   */
  const BASE_URL = "https://developerjose.netlify.app";
  /**
   * Base URL where all the music files are located.
   * @constant {string}
   */
  const BASE_MUSIC_URL = BASE_URL + "/music";
  /**
   * Base URL where all sound effect files are located.
   * @constant {string}
   */
  const BASE_SFX_URL = BASE_MUSIC_URL + "/sfx";
  /**
   * Image URL for static music player icon
   * @constant {string}
   */
  const NEUTRAL_IMG_URL = BASE_URL + "/img/music-player-icon.png";
  /**
   * Image URL for animated music player icon.
   * @constant {string}
   */
  const PLAYING_IMG_URL = BASE_URL + "/img/music-player-playing.gif";
  /**
   * URL for the JSON file containing the hashes for all the music files.
   * @constant {string}
   */
  const HASH_JSON_URL = BASE_MUSIC_URL + "/hashes.json";
  /**
   * URLs for the special themes that are not related to specific COs.
   * @enum {string}
   */
  var SpecialTheme;
  (function (SpecialTheme) {
    SpecialTheme["Victory"] = "https://developerjose.netlify.app/music/t-victory.ogg";
    SpecialTheme["Defeat"] = "https://developerjose.netlify.app/music/t-defeat.ogg";
    SpecialTheme["Maintenance"] = "https://developerjose.netlify.app/music/t-maintenance.ogg";
    SpecialTheme["COSelect"] = "https://developerjose.netlify.app/music/t-co-select.ogg";
    SpecialTheme["ModeSelect"] = "https://developerjose.netlify.app/music/t-mode-select.ogg";
  })(SpecialTheme || (SpecialTheme = {}));
  /**
   * Enumeration of all game sound effects. The values are the filenames for the sounds.
   * @enum {string}
   */
  var GameSFX;
  (function (GameSFX) {
    GameSFX["coGoldRush"] = "co-gold-rush";
    GameSFX["powerActivateAllyCOP"] = "power-activate-ally-cop";
    GameSFX["powerActivateAllySCOP"] = "power-activate-ally-scop";
    GameSFX["powerActivateBHCOP"] = "power-activate-bh-cop";
    GameSFX["powerActivateBHSCOP"] = "power-activate-bh-scop";
    GameSFX["powerActivateAW1COP"] = "power-activate-aw1-cop";
    GameSFX["powerSCOPAvailable"] = "power-scop-available";
    GameSFX["powerCOPAvailable"] = "power-cop-available";
    GameSFX["tagBreakAlly"] = "tag-break-ally";
    GameSFX["tagBreakBH"] = "tag-break-bh";
    GameSFX["tagSwap"] = "tag-swap";
    GameSFX["unitAttackPipeSeam"] = "unit-attack-pipe-seam";
    GameSFX["unitCaptureAlly"] = "unit-capture-ally";
    GameSFX["unitCaptureEnemy"] = "unit-capture-enemy";
    GameSFX["unitCaptureProgress"] = "unit-capture-progress";
    GameSFX["unitMissileHit"] = "unit-missile-hit";
    GameSFX["unitMissileSend"] = "unit-missile-send";
    GameSFX["unitHide"] = "unit-hide";
    GameSFX["unitUnhide"] = "unit-unhide";
    GameSFX["unitSupply"] = "unit-supply";
    GameSFX["unitTrap"] = "unit-trap";
    GameSFX["unitLoad"] = "unit-load";
    GameSFX["unitUnload"] = "unit-unload";
    GameSFX["unitExplode"] = "unit-explode";
    GameSFX["uiCursorMove"] = "ui-cursor-move";
    GameSFX["uiInvalid"] = "ui-invalid";
    GameSFX["uiMenuOpen"] = "ui-menu-open";
    GameSFX["uiMenuClose"] = "ui-menu-close";
    GameSFX["uiMenuMove"] = "ui-menu-move";
    GameSFX["uiUnitSelect"] = "ui-unit-select";
  })(GameSFX || (GameSFX = {}));
  /**
   * Enumeration of all the unit movement sounds. The values are the filenames for the sounds.
   * @enum {string}
   */
  var MovementSFX;
  (function (MovementSFX) {
    MovementSFX["moveBCopterLoop"] = "move-bcopter";
    MovementSFX["moveBCopterOneShot"] = "move-bcopter-rolloff";
    MovementSFX["moveInfLoop"] = "move-inf";
    MovementSFX["moveMechLoop"] = "move-mech";
    MovementSFX["moveNavalLoop"] = "move-naval";
    MovementSFX["movePiperunnerLoop"] = "move-piperunner";
    MovementSFX["movePlaneLoop"] = "move-plane";
    MovementSFX["movePlaneOneShot"] = "move-plane-rolloff";
    MovementSFX["moveSubLoop"] = "move-sub";
    MovementSFX["moveTCopterLoop"] = "move-tcopter";
    MovementSFX["moveTCopterOneShot"] = "move-tcopter-rolloff";
    MovementSFX["moveTiresHeavyLoop"] = "move-tires-heavy";
    MovementSFX["moveTiresHeavyOneShot"] = "move-tires-heavy-rolloff";
    MovementSFX["moveTiresLightLoop"] = "move-tires-light";
    MovementSFX["moveTiresLightOneShot"] = "move-tires-light-rolloff";
    MovementSFX["moveTreadHeavyLoop"] = "move-tread-heavy";
    MovementSFX["moveTreadHeavyOneShot"] = "move-tread-heavy-rolloff";
    MovementSFX["moveTreadLightLoop"] = "move-tread-light";
    MovementSFX["moveTreadLightOneShot"] = "move-tread-light-rolloff";
  })(MovementSFX || (MovementSFX = {}));
  /**
   * Map that takes unit names as keys and gives you the filename for that unit movement sound.
   */
  const onMovementStartMap = new Map([
    ["APC", MovementSFX.moveTreadLightLoop],
    ["Anti-Air", MovementSFX.moveTreadLightLoop],
    ["Artillery", MovementSFX.moveTreadLightLoop],
    ["B-Copter", MovementSFX.moveBCopterLoop],
    ["Battleship", MovementSFX.moveNavalLoop],
    ["Black Boat", MovementSFX.moveNavalLoop],
    ["Black Bomb", MovementSFX.movePlaneLoop],
    ["Bomber", MovementSFX.movePlaneLoop],
    ["Carrier", MovementSFX.moveNavalLoop],
    ["Cruiser", MovementSFX.moveNavalLoop],
    ["Fighter", MovementSFX.movePlaneLoop],
    ["Infantry", MovementSFX.moveInfLoop],
    ["Lander", MovementSFX.moveNavalLoop],
    ["Md. Tank", MovementSFX.moveTreadHeavyLoop],
    ["Mech", MovementSFX.moveMechLoop],
    ["Mega Tank", MovementSFX.moveTreadHeavyLoop],
    ["Missile", MovementSFX.moveTiresHeavyLoop],
    ["Neotank", MovementSFX.moveTreadHeavyLoop],
    ["Piperunner", MovementSFX.movePiperunnerLoop],
    ["Recon", MovementSFX.moveTiresLightLoop],
    ["Rocket", MovementSFX.moveTiresHeavyLoop],
    ["Stealth", MovementSFX.movePlaneLoop],
    ["Sub", MovementSFX.moveSubLoop],
    ["T-Copter", MovementSFX.moveTCopterLoop],
    ["Tank", MovementSFX.moveTreadLightLoop],
  ]);
  /**
   * Map that takes unit names as keys and gives you the filename to play when that unit has stopped moving, if any.
   */
  const onMovementRolloffMap = new Map([
    ["APC", MovementSFX.moveTreadLightOneShot],
    ["Anti-Air", MovementSFX.moveTreadLightOneShot],
    ["Artillery", MovementSFX.moveTreadLightOneShot],
    ["B-Copter", MovementSFX.moveBCopterOneShot],
    ["Black Bomb", MovementSFX.movePlaneOneShot],
    ["Bomber", MovementSFX.movePlaneOneShot],
    ["Fighter", MovementSFX.movePlaneOneShot],
    ["Md. Tank", MovementSFX.moveTreadHeavyOneShot],
    ["Mega Tank", MovementSFX.moveTreadHeavyOneShot],
    ["Missile", MovementSFX.moveTiresHeavyOneShot],
    ["Neotank", MovementSFX.moveTreadHeavyOneShot],
    ["Recon", MovementSFX.moveTiresLightOneShot],
    ["Rocket", MovementSFX.moveTiresHeavyOneShot],
    ["Stealth", MovementSFX.movePlaneOneShot],
    ["T-Copter", MovementSFX.moveTCopterOneShot],
    ["Tank", MovementSFX.moveTreadLightOneShot],
  ]);
  /**
   * Map that takes a game type and gives you a set of CO names that have alternate themes for that game type.
   */
  const alternateThemes = new Map([
    [GameType.AW1, new Set(["sturm"])],
    [GameType.AW2, new Set(["sturm"])],
    [GameType.RBC, new Set(["andy", "olaf", "eagle", "drake", "grit", "kanbei", "sonja", "sturm"])],
    [GameType.DS, new Set(["sturm", "vonbolt"])],
  ]);
  /**
   * Set of CO names that have special loops for their music.
   */
  const specialLoops = new Set(["vonbolt"]);
  /**
   * Determines the filename for the alternate music to play given a specific CO and other settings (if any).
   * @param coName - Name of the CO whose music to use.
   * @param gameType - Which game soundtrack to use.
   * @param themeType - Which type of music whether regular or power.
   * @returns - The filename of the alternate music to play given the parameters, if any.
   */
  function getAlternateMusicFilename(coName, gameType, themeType) {
    // Check if this CO has an alternate theme
    if (!alternateThemes.has(gameType)) return;
    const alternateThemesSet = alternateThemes.get(gameType);
    const faction = isBlackHoleCO(coName) ? "bh" : "ally";
    // RBC individual CO power themes -> RBC shared factory themes
    const isPowerActive = themeType !== ThemeType.REGULAR;
    if (gameType === GameType.RBC && isPowerActive) {
      return `t-${faction}-${themeType}`;
    }
    // No alternate theme or it's a power
    if (!alternateThemesSet?.has(coName) || isPowerActive) {
      return;
    }
    // Andy -> Clone Andy
    if (coName === "andy" && gameType == GameType.RBC) {
      return isPowerActive ? "t-clone-andy-cop" : "t-clone-andy";
    }
    // All other alternate themes
    return `t-${coName}-2`;
  }
  /**
   * Determines the filename for the music to play given a specific CO and other settings.
   * @param coName - Name of the CO whose music to use.
   * @param gameType - Which game soundtrack to use.
   * @param themeType - Which type of music whether regular or power.
   * @returns - The filename of the music to play given the parameters.
   */
  function getMusicFilename(coName, gameType, themeType, useAlternateTheme) {
    // Check if we want to play the map editor theme
    if (coName === "map-editor") return "t-map-editor";
    // Check if we need to play an alternate theme
    if (useAlternateTheme) {
      const alternateFilename = getAlternateMusicFilename(coName, gameType, themeType);
      if (alternateFilename) return alternateFilename;
    }
    // Regular theme, either no power or we are in AW1 where there's no power themes.
    const isPowerActive = themeType !== ThemeType.REGULAR;
    if (!isPowerActive || gameType === GameType.AW1) {
      return `t-${coName}`;
    }
    // For RBC, we play the new power themes (if they are not in the DS games obviously)
    const isCOInRBC = !AW_DS_ONLY_COs.has(coName);
    if (gameType === GameType.RBC && isCOInRBC) {
      return `t-${coName}-cop`;
    }
    // For all other games, play the ally or black hole themes during the CO and Super CO powers
    const faction = isBlackHoleCO(coName) ? "bh" : "ally";
    return `t-${faction}-${themeType}`;
  }
  /**
   * Determines the URL for the music to play given a specific CO, and optionally, some specific settings.
   * The settings will be loaded from the current saved settings if they aren't specified.
   *
   * @param coName - Name of the CO whose music to use.
   * @param gameType - (Optional) Which game soundtrack to use.
   * @param themeType - (Optional) Which type of music to use whether regular or power.
   * @param useAlternateTheme - (Optional) Whether to use the alternate theme for the given CO.
   * @returns - The complete URL of the music to play given the parameters.
   */
  function getMusicURL(coName, gameType, themeType, useAlternateTheme) {
    // Override optional parameters with current settings if not provided
    if (gameType === null || gameType === undefined) gameType = musicSettings.gameType;
    if (themeType === null || themeType === undefined) themeType = musicSettings.themeType;
    if (useAlternateTheme === null || useAlternateTheme === undefined) {
      useAlternateTheme = getCurrentGameDay() >= musicSettings.alternateThemeDay && musicSettings.alternateThemes;
    }
    // Convert name to internal format
    coName = coName.toLowerCase().replaceAll(" ", "");
    // Check if we want to play a special theme;
    if (coName === "victory") return "https://developerjose.netlify.app/music/t-victory.ogg" /* SpecialTheme.Victory */;
    if (coName === "defeat") return "https://developerjose.netlify.app/music/t-defeat.ogg" /* SpecialTheme.Defeat */;
    if (coName === "co-select")
      return "https://developerjose.netlify.app/music/t-co-select.ogg" /* SpecialTheme.COSelect */;
    if (coName === "mode-select")
      return "https://developerjose.netlify.app/music/t-mode-select.ogg" /* SpecialTheme.ModeSelect */;
    if (coName === "maintenance")
      return "https://developerjose.netlify.app/music/t-maintenance.ogg" /* SpecialTheme.Maintenance */;
    // First apply player overrides, that way we can override their overrides later...
    const overrideType = musicSettings.getOverride(coName);
    if (overrideType) gameType = overrideType;
    // Override the game type to a higher game if the CO is not available in the current game.
    if (gameType !== GameType.DS && AW_DS_ONLY_COs.has(coName)) gameType = GameType.DS;
    if (gameType === GameType.AW1 && AW2_ONLY_COs.has(coName)) gameType = GameType.AW2;
    let gameDir = gameType;
    if (!gameDir.startsWith("AW")) gameDir = "AW_" + gameDir;
    const filename = getMusicFilename(coName, gameType, themeType, useAlternateTheme);
    const url = `${BASE_MUSIC_URL}/${gameDir}/${filename}.ogg`;
    return url.toLowerCase().replaceAll("_", "-").replaceAll(" ", "");
  }
  /**
   * Gets the name of the CO from the given URL, if any.
   * @param url - URL to get the CO name from.
   * @returns - The name of the CO from the given URL.
   */
  function getCONameFromURL(url) {
    const parts = url.split("/");
    const filename = parts[parts.length - 1];
    // Remove t- prefix and .ogg extension
    const coName = filename.split(".")[0].substring(2);
    return coName;
  }
  /**
   * Gets the URL for the given sound effect.
   * @param sfx - Sound effect enum to use.
   * @returns - The URL of the given sound effect.
   */
  function getSoundEffectURL(sfx) {
    return `${BASE_SFX_URL}/${sfx}.ogg`;
  }
  /**
   * Gets the URL to play when the given unit starts to move.
   * @param unitName - Name of the unit.
   * @returns - The URL of the given unit's movement start sound.
   */
  function getMovementSoundURL(unitName) {
    return `${BASE_SFX_URL}/${onMovementStartMap.get(unitName)}.ogg`;
  }
  /**
   * Getes the URL to play when the given unit stops moving, if any.
   * @param unitName - Name of the unit.
   * @returns - The URL of the given unit's movement stop sound, if any, or null otherwise.
   */
  function getMovementRollOffURL(unitName) {
    return `${BASE_SFX_URL}/${onMovementRolloffMap.get(unitName)}.ogg`;
  }
  /**
   * Checks if the given unit plays a sound when it stops moving.
   * @param unitName - Name of the unit.
   * @returns - True if the given unit has a sound to play when it stops moving.
   */
  function hasMovementRollOff(unitName) {
    return onMovementRolloffMap.has(unitName);
  }
  /**
   * Checks if the given URL has a special loop to play after the music finishes.
   * @param srcURL - URL of the music to check.
   * @returns - True if the given URL has a special loop to play after the audio finishes.
   */
  function hasSpecialLoop(srcURL) {
    const coName = getCONameFromURL(srcURL);
    return specialLoops.has(coName);
  }
  /**
   * Gets all the URLs for the music of all currently playing COs for the current game settings.
   * Includes the regular and alternate themes for each CO (if any).
   * @returns - Set with all the URLs for current music of all currently playing COs.
   */
  function getCurrentThemeURLs() {
    const coNames = getAllPlayingCONames();
    const audioList = new Set();
    coNames.forEach((name) => {
      const regularURL = getMusicURL(name, musicSettings.gameType, ThemeType.REGULAR, false);
      const powerURL = getMusicURL(name, musicSettings.gameType, ThemeType.CO_POWER, false);
      const superPowerURL = getMusicURL(name, musicSettings.gameType, ThemeType.SUPER_CO_POWER, false);
      const alternateURL = getMusicURL(name, musicSettings.gameType, musicSettings.themeType, true);
      audioList.add(regularURL);
      audioList.add(alternateURL);
      audioList.add(powerURL);
      audioList.add(superPowerURL);
      if (specialLoops.has(name)) audioList.add(regularURL.replace(".ogg", "-loop.ogg"));
    });
    return audioList;
  }

  /**
   * @file This file contains all the functions and variables relevant to the creation and behavior of a custom UI.
   */
  var CustomInputType;
  (function (CustomInputType) {
    CustomInputType["Radio"] = "radio";
    CustomInputType["Checkbox"] = "checkbox";
    CustomInputType["Button"] = "button";
  })(CustomInputType || (CustomInputType = {}));
  var GroupType;
  (function (GroupType) {
    GroupType["Vertical"] = "cls-vertical-box";
    GroupType["Horizontal"] = "cls-horizontal-box";
  })(GroupType || (GroupType = {}));
  var MenuPosition;
  (function (MenuPosition) {
    MenuPosition["Left"] = "settings-left";
    MenuPosition["Center"] = "settings-center";
    MenuPosition["Right"] = "settings-right";
  })(MenuPosition || (MenuPosition = {}));
  function sanitize(str) {
    return str.toLowerCase().replaceAll(" ", "-");
  }
  /**
   * A class that represents a custom menu UI that can be added to the AWBW page.
   */
  class CustomMenuSettingsUI {
    /**
     * The root element or parent of the custom menu.
     */
    parent;
    /**
     * A map that contains the important nodes of the menu.
     * The keys are the names of the children, and the values are the elements themselves.
     * Allows for easy access to any element in the menu.
     */
    groups = new Map();
    /**
     * A map that contains the group types for each group in the menu.
     * The keys are the names of the groups, and the values are the types of the groups.
     */
    groupTypes = new Map();
    /**
     * An array of all the input elements in the menu.
     */
    inputElements = [];
    /**
     * An array of all the button elements in the menu.
     */
    buttonElements = [];
    /**
     * A boolean that represents whether the settings menu is open or not.
     */
    isSettingsMenuOpen = false;
    /**
     * A string used to prefix the IDs of the elements in the menu.
     */
    prefix = "";
    /**
     * Text to be displayed when hovering over the main button.
     */
    parentHoverText = "";
    tableMap = new Map();
    /**
     * Creates a new Custom Menu UI, to add it to AWBW you need to call {@link addToAWBWPage}.
     * @param prefix - A string used to prefix the IDs of the elements in the menu.
     * @param buttonImageURL - The URL of the image to be used as the button.
     * @param hoverText - The text to be displayed when hovering over the button.
     */
    constructor(prefix, buttonImageURL, hoverText = "") {
      this.prefix = prefix;
      this.parentHoverText = hoverText;
      this.parent = document.createElement("div");
      this.parent.id = `${prefix}-parent`;
      this.parent.classList.add("game-tools-btn");
      this.parent.style.width = "34px";
      this.parent.style.height = "30px";
      // Hover text
      const hoverSpan = document.createElement("span");
      hoverSpan.id = `${prefix}-hover-span`;
      hoverSpan.classList.add("game-tools-btn-text", "small_text");
      hoverSpan.innerText = hoverText;
      this.parent.appendChild(hoverSpan);
      this.groups.set("hover", hoverSpan);
      // Button Background
      const bgDiv = document.createElement("div");
      bgDiv.id = `${prefix}-background`;
      bgDiv.classList.add("game-tools-bg");
      bgDiv.style.backgroundImage = "linear-gradient(to right, #ffffff 0% , #888888 0%)";
      this.parent.appendChild(bgDiv);
      this.groups.set("bg", bgDiv);
      // Reset hover text for parent button
      bgDiv.addEventListener("mouseover", () => this.setHoverText(this.parentHoverText));
      bgDiv.addEventListener("mouseout", () => this.setHoverText(""));
      // Button
      const btnLink = document.createElement("a");
      btnLink.id = `${prefix}-link`;
      btnLink.classList.add("norm2");
      bgDiv.appendChild(btnLink);
      const btnImg = document.createElement("img");
      btnImg.id = `${prefix}-link-img`;
      btnImg.src = buttonImageURL;
      btnLink.appendChild(btnImg);
      this.groups.set("img", btnImg);
      // Context Menu
      const contextMenu = document.createElement("div");
      contextMenu.id = `${prefix}-settings`;
      contextMenu.classList.add("cls-settings-menu");
      contextMenu.style.zIndex = "20";
      this.parent.appendChild(contextMenu);
      this.groups.set("settings-parent", contextMenu);
      const contextMenuBoxesContainer = document.createElement("div");
      contextMenuBoxesContainer.id = `${prefix}-settings-container`;
      contextMenuBoxesContainer.classList.add("cls-horizontal-box");
      contextMenu.appendChild(contextMenuBoxesContainer);
      this.groups.set("settings", contextMenuBoxesContainer);
      // Context Menu 3 Boxes
      const leftBox = document.createElement("div");
      leftBox.id = `${prefix}-settings-left`;
      leftBox.classList.add("cls-settings-menu-box");
      leftBox.style.display = "none";
      contextMenuBoxesContainer.appendChild(leftBox);
      this.groups.set(MenuPosition.Left, leftBox);
      const centerBox = document.createElement("div");
      centerBox.id = `${prefix}-settings-center`;
      centerBox.classList.add("cls-settings-menu-box");
      centerBox.style.display = "none";
      contextMenuBoxesContainer.appendChild(centerBox);
      this.groups.set(MenuPosition.Center, centerBox);
      const rightBox = document.createElement("div");
      rightBox.id = `${prefix}-settings-right`;
      rightBox.classList.add("cls-settings-menu-box");
      rightBox.style.display = "none";
      contextMenuBoxesContainer.appendChild(rightBox);
      this.groups.set(MenuPosition.Right, rightBox);
      // Enable right-click to open and close the context menu
      this.parent.addEventListener("contextmenu", (event) => {
        const element = event.target;
        if (element.id.startsWith(prefix)) {
          event.preventDefault();
          this.isSettingsMenuOpen = !this.isSettingsMenuOpen;
          if (this.isSettingsMenuOpen) {
            this.openContextMenu();
          } else {
            this.closeContextMenu();
          }
        }
      });
      // Close settings menu whenever the user clicks anywhere outside the player
      document.addEventListener("click", (event) => {
        let elmnt = event.target;
        // Find the first parent that has an ID if the element doesn't have one
        if (!elmnt.id) {
          while (!elmnt.id) {
            elmnt = elmnt.parentNode;
            // Break if we reach the top of the document or this element isn't properly connected
            if (!elmnt) break;
          }
        }
        // Most likely this element is part of our UI and was created with JS and not properly connected so don't close
        if (!elmnt) return;
        // Check if we are in the music player or the overlib overDiv, so we don't close the menu
        if (elmnt.id.startsWith(prefix) || elmnt.id === "overDiv") return;
        // Close the menu if we clicked outside of it
        // console.debug("[MP] Clicked on: ", elmnt.id);
        this.closeContextMenu();
      });
    }
    /**
     * Adds the custom menu to the AWBW page.
     */
    addToAWBWPage(div, prepend = false) {
      if (!prepend) {
        div.appendChild(this.parent);
        this.parent.style.borderLeft = "none";
        return;
      }
      div.prepend(this.parent);
      this.parent.style.borderRight = "none";
    }
    getGroup(groupName) {
      const container = this.groups.get(groupName);
      // Unhide group
      if (!container) return;
      if (container.style.display === "none") container.style.display = "flex";
      return container;
    }
    /**
     * Changes the hover text of the main button.
     * @param text - The text to be displayed when hovering over the button.
     * @param replaceParent - Whether to replace the current hover text for the main button or not.
     */
    setHoverText(text, replaceParent = false) {
      const hoverSpan = this.groups.get("hover");
      if (!hoverSpan) return;
      if (replaceParent) this.parentHoverText = text;
      hoverSpan.innerText = text;
      hoverSpan.style.display = text === "" ? "none" : "block";
    }
    /**
     * Sets the progress of the UI by coloring the background of the main button.
     * @param progress - A number between 0 and 100 representing the percentage of the progress bar to fill.
     */
    setProgress(progress) {
      const bgDiv = this.groups.get("bg");
      if (!bgDiv) return;
      if (progress < 0) {
        bgDiv.style.backgroundImage = "";
        return;
      }
      bgDiv.style.backgroundImage = "linear-gradient(to right, #ffffff " + String(progress) + "% , #888888 0%)";
    }
    /**
     * Sets the image of the main button.
     * @param imageURL - The URL of the image to be used on the button.
     */
    setImage(imageURL) {
      const btnImg = this.groups.get("img");
      btnImg.src = imageURL;
    }
    /**
     * Adds an event listener to the main button.
     * @param type - The type of event to listen for.
     * @param listener - The function to be called when the event is triggered.
     */
    addEventListener(type, listener, options = false) {
      const div = this.groups.get("bg");
      div?.addEventListener(type, listener, options);
    }
    /**
     * Opens the context (right-click) menu.
     */
    openContextMenu() {
      const contextMenu = this.groups.get("settings-parent");
      if (!contextMenu) return;
      // No settings so don't open the menu
      const hasLeftMenu = this.groups.get(MenuPosition.Left)?.style.display !== "none";
      const hasCenterMenu = this.groups.get(MenuPosition.Center)?.style.display !== "none";
      const hasRightMenu = this.groups.get(MenuPosition.Right)?.style.display !== "none";
      if (!hasLeftMenu && !hasCenterMenu && !hasRightMenu) return;
      contextMenu.style.display = "flex";
      this.isSettingsMenuOpen = true;
    }
    /**
     * Closes the context (right-click) menu.
     */
    closeContextMenu() {
      const contextMenu = this.groups.get("settings-parent");
      if (!contextMenu) return;
      contextMenu.style.display = "none";
      this.isSettingsMenuOpen = false;
      // Check if we have a CO selector and need to hide it
      const overDiv = document.querySelector("#overDiv");
      const hasCOSelector = this.groups.has("co-selector");
      if (overDiv && hasCOSelector && isGamePageAndActive()) {
        overDiv.style.visibility = "hidden";
      }
    }
    /**
     * Adds an input slider to the context menu.
     * @param name - The name of the slider.
     * @param min - The minimum value of the slider.
     * @param max - The maximum value of the slider.
     * @param step - The step value of the slider.
     * @param hoverText - The text to be displayed when hovering over the slider.
     * @param position - The position of the slider in the context menu.
     * @returns - The slider element.
     */
    addSlider(name, min, max, step, hoverText = "", position = MenuPosition.Center) {
      const contextMenu = this.getGroup(position);
      if (!contextMenu) return;
      // Container for the slider and label
      const sliderBox = document.createElement("div");
      sliderBox.classList.add("cls-vertical-box");
      sliderBox.classList.add("cls-group-box");
      contextMenu?.appendChild(sliderBox);
      // Slider label
      const label = document.createElement("label");
      sliderBox?.appendChild(label);
      // Slider
      const slider = document.createElement("input");
      slider.id = `${this.prefix}-${sanitize(name)}`;
      slider.type = "range";
      slider.min = String(min);
      slider.max = String(max);
      slider.step = String(step);
      this.inputElements.push(slider);
      // Set the label to the current value of the slider
      slider.addEventListener("input", (_e) => {
        let displayValue = slider.value;
        if (max === 1) displayValue = Math.round(parseFloat(displayValue) * 100) + "%";
        label.innerText = `${name}: ${displayValue}`;
      });
      sliderBox?.appendChild(slider);
      // Hover text
      slider.title = hoverText;
      slider.addEventListener("mouseover", () => this.setHoverText(hoverText));
      slider.addEventListener("mouseout", () => this.setHoverText(""));
      return slider;
    }
    addGroup(groupName, type = GroupType.Horizontal, position = MenuPosition.Center) {
      const contextMenu = this.getGroup(position);
      if (!contextMenu) return;
      // Container for the label and group inner container
      const groupBox = document.createElement("div");
      groupBox.classList.add("cls-vertical-box");
      groupBox.classList.add("cls-group-box");
      contextMenu?.appendChild(groupBox);
      // Label for the group
      const groupLabel = document.createElement("label");
      groupLabel.innerText = groupName;
      groupBox?.appendChild(groupLabel);
      // Group container
      const group = document.createElement("div");
      group.id = `${this.prefix}-${sanitize(groupName)}`;
      group.classList.add(type);
      groupBox?.appendChild(group);
      this.groups.set(groupName, group);
      this.groupTypes.set(groupName, type);
      return group;
    }
    addRadioButton(name, groupName, hoverText = "") {
      return this.addInput(name, groupName, hoverText, CustomInputType.Radio);
    }
    addCheckbox(name, groupName, hoverText = "") {
      return this.addInput(name, groupName, hoverText, CustomInputType.Checkbox);
    }
    addButton(name, groupName, hoverText = "") {
      return this.addInput(name, groupName, hoverText, CustomInputType.Button);
    }
    /**
     * Adds an input to the context menu in a specific group.
     * @param name - The name of the input.
     * @param groupName - The name of the group the input belongs to.
     * @param hoverText - The text to be displayed when hovering over the input.
     * @param type - The type of input to be added.
     * @returns - The input element.
     */
    addInput(name, groupName, hoverText = "", type) {
      // Check if the group already exists
      const groupDiv = this.getGroup(groupName);
      const groupType = this.groupTypes.get(groupName);
      if (!groupDiv || !groupType) return;
      // Container for input and label
      const inputBox = document.createElement("div");
      const otherType = groupType === GroupType.Horizontal ? GroupType.Vertical : GroupType.Horizontal;
      inputBox.classList.add(otherType);
      groupDiv.appendChild(inputBox);
      // Hover text
      inputBox.title = hoverText;
      inputBox.addEventListener("mouseover", () => this.setHoverText(hoverText));
      inputBox.addEventListener("mouseout", () => this.setHoverText(""));
      // Create button or a different type of input
      let input;
      if (type === CustomInputType.Button) {
        input = this.createButton(name, inputBox);
      } else {
        input = this.createInput(name, inputBox);
      }
      // Set the rest of the shared input properties
      input.type = type;
      input.name = groupName;
      return input;
    }
    createButton(name, inputBox) {
      // Buttons don't need a separate label
      const input = document.createElement("button");
      input.innerText = name;
      inputBox.appendChild(input);
      this.buttonElements.push(input);
      return input;
    }
    createInput(name, inputBox) {
      // Create the input and a label for it
      const input = document.createElement("input");
      const label = document.createElement("label");
      label.innerText = name;
      // Input first, then label
      inputBox.appendChild(input);
      inputBox.appendChild(label);
      // Propagate label clicks to the input
      label.addEventListener("click", () => input.click());
      this.inputElements.push(input);
      return input;
    }
    /**
     * Adds a special version label to the context menu.
     * @param version - The version to be displayed.
     */
    addVersion(version) {
      const contextMenu = this.groups.get("settings-parent");
      const versionDiv = document.createElement("label");
      versionDiv.id = this.prefix + "-version";
      versionDiv.innerText = `Version: ${version} (DeveloperJose Edition)`;
      contextMenu?.appendChild(versionDiv);
    }
    addTable(name, rows, columns, groupName, hoverText = "") {
      const groupDiv = this.getGroup(groupName);
      if (!groupDiv) return;
      const table = document.createElement("table");
      table.classList.add("cls-settings-table");
      groupDiv.appendChild(table);
      // Hover text
      table.title = hoverText;
      table.addEventListener("mouseover", () => this.setHoverText(hoverText));
      table.addEventListener("mouseout", () => this.setHoverText(""));
      const tableData = {
        table,
        rows,
        columns,
      };
      this.tableMap.set(name, tableData);
      return table;
    }
    addItemToTable(name, item) {
      const tableData = this.tableMap.get(name);
      if (!tableData) return;
      const table = tableData.table;
      // Check if we need to create the first row
      if (table.rows.length === 0) table.insertRow();
      // Check if the row is full
      const maxItemsPerRow = tableData.columns;
      const currentItemsInRow = table.rows[table.rows.length - 1].cells.length;
      if (currentItemsInRow >= maxItemsPerRow) table.insertRow();
      // Add the item to the last row
      const currentRow = table.rows[table.rows.length - 1];
      const cell = currentRow.insertCell();
      cell.appendChild(item);
    }
    clearTable(name) {
      const tableData = this.tableMap.get(name);
      if (!tableData) return;
      const table = tableData.table;
      table.innerHTML = "";
    }
    /**
     * Calls the input event on all input elements in the menu.
     * Useful for updating the labels of all the inputs.
     */
    updateAllInputLabels() {
      const event = new Event("input");
      this.inputElements.forEach((input) => {
        input.dispatchEvent(event);
      });
    }
    /**
     * Adds a CO selector to the context menu. Only one CO selector can be added to the menu.
     * @param groupName - The name of the group the CO selector should be added to.
     * @param hoverText - The text to be displayed when hovering over the CO selector.
     * @param onClickFn - The function to be called when a CO is selected from the selector.
     * @returns - The CO selector element.
     */
    addCOSelector(groupName, hoverText = "", onClickFn) {
      const groupDiv = this.getGroup(groupName);
      if (!groupDiv) return;
      const coSelector = document.createElement("a");
      coSelector.classList.add("game-tools-btn");
      coSelector.href = "javascript:void(0)";
      const imgCaret = this.createCOSelectorCaret();
      const imgCO = this.createCOPortraitImage("andy");
      coSelector.appendChild(imgCaret);
      coSelector.appendChild(imgCO);
      // Hover text
      coSelector.title = hoverText;
      coSelector.addEventListener("mouseover", () => this.setHoverText(hoverText));
      coSelector.addEventListener("mouseout", () => this.setHoverText(""));
      // Update UI
      this.groups.set("co-selector", coSelector);
      this.groups.set("co-portrait", imgCO);
      groupDiv?.appendChild(coSelector);
      // Sort all the COs alphabetically, get their proper names
      const allCOs = getAllCONames(true).sort();
      // Prepare the CO selector HTML with overlib (style taken from AWBW)
      let allColumnsHTML = "";
      for (let i = 0; i < 7; i++) {
        const startIDX = i * 4;
        const endIDX = startIDX + 4;
        const templateFn = (coName) => this.createCOSelectorItem(coName);
        const currentColumnHTML = allCOs.slice(startIDX, endIDX).map(templateFn).join("");
        allColumnsHTML += `<td><table>${currentColumnHTML}</table></td>`;
      }
      const selectorInnerHTML = `<table><tr>${allColumnsHTML}</tr></table>`;
      const selectorTitle = `<img src=terrain/ani/blankred.gif height=16 width=1 align=absmiddle>Select CO`;
      // Make the CO selector that will appear when the user clicks on the CO portrait
      coSelector.onclick = () => {
        return overlib(selectorInnerHTML, STICKY, CAPTION, selectorTitle, OFFSETY, 25, OFFSETX, -322, CLOSECLICK);
      };
      // Listen for clicks on the CO selector
      addCOSelectorListener((coName) => this.onCOSelectorClick(coName));
      addCOSelectorListener(onClickFn);
      return coSelector;
    }
    createCOSelectorItem(coName) {
      const location = "javascript:void(0)";
      const internalName = coName.toLowerCase().replaceAll(" ", "");
      const imgSrc = `terrain/ani/aw2${internalName}.png?v=1`;
      const onClickFn = `awbw_music_player.notifyCOSelectorListeners('${internalName}');`;
      return (
        `<tr>` +
        `<td class=borderwhite><img class=co_portrait src=${imgSrc}></td>` +
        `<td class=borderwhite align=center valign=center>` +
        `<span class=small_text>` +
        `<a onclick="${onClickFn}" href=${location}>${coName}</a></b>` +
        `</span>` +
        `</td>` +
        `</tr>`
      );
    }
    createCOSelectorCaret() {
      const imgCaret = document.createElement("img");
      imgCaret.classList.add("co_caret");
      imgCaret.src = "terrain/co_down_caret.gif";
      return imgCaret;
    }
    createCOPortraitImage(coName) {
      const imgCO = document.createElement("img");
      imgCO.classList.add("co_portrait");
      imgCO.src = `terrain/ani/aw2${coName}.png?v=1`;
      // Allows other icons to be used
      if (!getAllCONames().includes(coName)) {
        imgCO.src = `terrain/${coName}`;
      }
      return imgCO;
    }
    createCOPortraitImageWithText(coName, text) {
      const div = document.createElement("div");
      div.classList.add("cls-vertical-box");
      // CO picture
      const coImg = this.createCOPortraitImage(coName);
      div.appendChild(coImg);
      // Text
      const coLabel = document.createElement("label");
      coLabel.textContent = text;
      div.appendChild(coLabel);
      return div;
    }
    onCOSelectorClick(coName) {
      // Hide the CO selector
      const overDiv = document.querySelector("#overDiv");
      overDiv.style.visibility = "hidden";
      // Change the CO portrait
      const imgCO = this.groups.get("co-portrait");
      imgCO.src = `terrain/ani/aw2${coName}.png?v=1`;
    }
  }
  const coSelectorListeners = [];
  function addCOSelectorListener(listener) {
    coSelectorListeners.push(listener);
  }
  function notifyCOSelectorListeners(coName) {
    coSelectorListeners.forEach((listener) => listener(coName));
  }

  /**
   * @file Constants and other project configuration settings that could be used by any scripts.
   */

  /**
   * The version numbers of the userscripts.
   * @constant {Object.<string, string>}
   */
  const versions = {
    music_player: "4.3.0",
    highlight_cursor_coordinates: "2.0.2",
  };

  /**
   * @file This file contains all the functions and variables relevant to the creation and behavior of the music player UI.
   */
  // Listen for setting changes to update the menu UI
  addSettingsChangeListener(onSettingsChange$1);
  /**
   * Event handler for when the music button is clicked that turns the music ON/OFF.
   * @param _event - Click event handler, not used.
   */
  function onMusicBtnClick(_event) {
    musicSettings.isPlaying = !musicSettings.isPlaying;
  }
  /**
   * Event handler that is triggered whenever the settings of the music player are changed.
   * Updates the music player settings UI (context menu) so it matches the internal settings when the settings change.
   *
   * The context menu is the menu that appears when you right-click the player that shows you options.
   * This function ensures that the internal settings are reflected properly on the UI.
   * @param key - Name of the setting that changed, matches the name of the property in {@link musicSettings}.
   * @param isFirstLoad - Whether this is the first time the settings are being loaded.
   */
  function onSettingsChange$1(key, isFirstLoad) {
    // We are loading settings stored in LocalStorage, so set the initial values of all inputs.
    // Only do this once, when the settings are first loaded, otherwise it's infinite recursion.
    if (isFirstLoad) {
      if (volumeSlider) volumeSlider.value = musicSettings.volume.toString();
      if (sfxVolumeSlider) sfxVolumeSlider.value = musicSettings.sfxVolume.toString();
      if (uiVolumeSlider) uiVolumeSlider.value = musicSettings.uiVolume.toString();
      if (daySlider) daySlider.value = musicSettings.alternateThemeDay.toString();
      const selectedGameTypeRadio = gameTypeRadioMap.get(musicSettings.gameType);
      if (selectedGameTypeRadio) selectedGameTypeRadio.checked = true;
      const selectedRandomTypeRadio = randomRadioMap.get(musicSettings.randomThemesType);
      if (selectedRandomTypeRadio) selectedRandomTypeRadio.checked = true;
      captProgressBox.checked = musicSettings.captureProgressSFX;
      pipeSeamBox.checked = musicSettings.pipeSeamSFX;
      restartThemesBox.checked = musicSettings.restartThemes;
      autoplayPagesBox.checked = musicSettings.autoplayOnOtherPages;
      alternateThemesBox.checked = musicSettings.alternateThemes;
      // Update all labels
      musicPlayerUI.updateAllInputLabels();
    }
    // Sort overrides again if we are loading the settings for the first time, or if the override list changed
    if (key === SettingsKey.ALL || key === SettingsKey.ADD_OVERRIDE || key === SettingsKey.REMOVE_OVERRIDE) {
      clearAndRepopulateOverrideList();
      if (musicSettings.overrideList.size === 0) {
        const noOverrides = musicPlayerUI.createCOPortraitImageWithText("followlist.gif", "No overrides set yet...");
        musicPlayerUI.addItemToTable(Name.Override_Table, noOverrides);
      }
    }
    if (key === SettingsKey.ALL || key === SettingsKey.ADD_EXCLUDED || key === SettingsKey.REMOVE_EXCLUDED) {
      clearAndRepopulateExcludedList();
      if (musicSettings.excludedRandomThemes.size === 0) {
        const noExcluded = musicPlayerUI.createCOPortraitImageWithText("followlist.gif", "No themes excluded yet...");
        musicPlayerUI.addItemToTable(Name.Excluded_Table, noExcluded);
      }
    }
    // Update UI
    const canUpdateDaySlider = daySlider?.parentElement && isGamePageAndActive();
    if (canUpdateDaySlider) daySlider.parentElement.style.display = alternateThemesBox.checked ? "flex" : "none";
    if (shuffleBtn) shuffleBtn.disabled = musicSettings.randomThemesType === RandomThemeType.NONE;
    // Update player image and hover text
    const currentSounds = isMovePlanner() ? "Sound Effects" : "Tunes";
    if (musicSettings.isPlaying) {
      musicPlayerUI.setHoverText(`Stop ${currentSounds}`, true);
      musicPlayerUI.setImage(PLAYING_IMG_URL);
    } else {
      musicPlayerUI.setHoverText(`Play ${currentSounds}`, true);
      musicPlayerUI.setImage(NEUTRAL_IMG_URL);
    }
  }
  /**
   * Parses the value of an input event as a float.
   * @param event - Input event to parse the value from.
   * @returns - The parsed float value of the input event.
   */
  const parseInputFloat = (event) => parseFloat(event.target.value);
  /**
   * Parses the value of an input event as an integer.
   * @param event  - Input event to parse the value from.
   * @returns - The parsed integer value of the input event.
   */
  const parseInputInt = (event) => parseInt(event.target.value);
  /************************************ Create the music player UI *************************************/
  /**
   * The music player UI for the settings.
   */
  const musicPlayerUI = new CustomMenuSettingsUI("music-player", NEUTRAL_IMG_URL, "Play Tunes");
  // Determine who will catch when the user clicks the play/stop button
  musicPlayerUI.addEventListener("click", onMusicBtnClick);
  var Name;
  (function (Name) {
    Name["Volume"] = "Music Volume";
    Name["SFX_Volume"] = "SFX Volume";
    Name["UI_Volume"] = "UI Volume";
    Name["No_Random"] = "Off";
    Name["All_Random"] = "All Soundtracks";
    Name["Current_Random"] = "Current Soundtrack";
    Name["Shuffle"] = "Shuffle";
    Name["Capture_Progress"] = "Capture Progress SFX";
    Name["Pipe_Seam_SFX"] = "Pipe Seam Attack SFX";
    Name["Restart_Themes"] = "Restart Themes Every Turn";
    Name["Autoplay_Pages"] = "Autoplay Music On Other Pages";
    Name["Alternate_Themes"] = "Alternate Themes";
    Name["Alternate_Day"] = "Alternate Themes Start On Day";
    Name["Add_Override"] = "Add";
    Name["Override_Table"] = "Overrides";
    Name["Excluded_Table"] = "Excluded Random Themes";
  })(Name || (Name = {}));
  var Description;
  (function (Description) {
    Description["Volume"] = "Adjust the volume of the CO theme music, power activations, and power themes.";
    Description["SFX_Volume"] = "Adjust the volume of the unit movement, tag swap, captures, and other unit sounds.";
    Description["UI_Volume"] =
      "Adjust the volume of the UI sound effects like moving your cursor, opening menus, and selecting units.";
    Description["AW1"] = "Play the Advance Wars 1 soundtrack. There are no power themes just like the cartridge!";
    Description["AW2"] = "Play the Advance Wars 2 soundtrack. Very classy like Md Tanks.";
    Description["DS"] =
      "Play the Advance Wars: Dual Strike soundtrack. A bit better quality than with the DS speakers though.";
    Description["RBC"] = "Play the Advance Wars: Re-Boot Camp soundtrack. Even the new power themes are here now!";
    Description["No_Random"] = "Play the music depending on who the current CO is.";
    Description["All_Random"] = "Play random music every turn from all soundtracks.";
    Description["Current_Random"] = "Play random music every turn from the current soundtrack.";
    Description["Shuffle"] = "Changes the current theme to a new random one.";
    Description["Capture_Progress"] = "Play a sound effect when a unit makes progress capturing a property.";
    Description["Pipe_Seam_SFX"] = "Play a sound effect when a pipe seam is attacked.";
    Description["Restart_Themes"] =
      "Restart themes at the beginning of each turn (including replays). If disabled, themes will continue from where they left off previously.";
    Description["Autoplay_Pages"] = "Autoplay music on other pages like your games or during maintenance.";
    Description["Alternate_Themes"] =
      "Play alternate themes like the Re-Boot Camp factory themes after a certain day. Enable this to be able to select what day alternate themes start.";
    Description["Alternate_Day"] =
      "After what day should alternate themes like the Re-Boot Camp factory themes start playing? Can you find all the hidden themes?";
    Description["Add_Override"] =
      "Adds an override for a specific CO so it always plays a specific soundtrack or to exclude it when playing random themes.";
    Description["Override_Radio"] = "Only play songs from ";
    Description["Remove_Override"] = "Removes the override for this specific CO.";
    Description["Add_Excluded"] =
      "Add an override for a specific CO to exclude their themes when playing random themes.";
  })(Description || (Description = {}));
  /* ************************************ Left Menu ************************************ */
  const LEFT = MenuPosition.Left;
  /* **** Group: Volume sliders **** */
  const volumeSlider = musicPlayerUI.addSlider(Name.Volume, 0, 1, 0.005, Description.Volume, LEFT);
  const sfxVolumeSlider = musicPlayerUI.addSlider(Name.SFX_Volume, 0, 1, 0.005, Description.SFX_Volume, LEFT);
  const uiVolumeSlider = musicPlayerUI.addSlider(Name.UI_Volume, 0, 1, 0.005, Description.UI_Volume, LEFT);
  volumeSlider?.addEventListener("input", (event) => (musicSettings.volume = parseInputFloat(event)));
  sfxVolumeSlider?.addEventListener("input", (event) => (musicSettings.sfxVolume = parseInputFloat(event)));
  uiVolumeSlider?.addEventListener("input", (event) => (musicSettings.uiVolume = parseInputFloat(event)));
  /* **** Group: Soundtrack radio buttons (AW1, AW2, DS, RBC) AKA GameType **** */
  const soundtrackGroup = "Soundtrack";
  const soundtrackGroupDiv = musicPlayerUI.addGroup(soundtrackGroup, GroupType.Horizontal, LEFT);
  // Radio buttons
  const gameTypeRadioMap = new Map();
  for (const gameType of Object.values(GameType)) {
    const description = Description[gameType];
    const radio = musicPlayerUI.addRadioButton(gameType, soundtrackGroup, description);
    gameTypeRadioMap.set(gameType, radio);
    radio.addEventListener("click", (_e) => (musicSettings.gameType = gameType));
  }
  /* **** Group: Random themes radio buttons **** */
  const randomGroup = "Random Themes";
  const randomGroupDiv = musicPlayerUI.addGroup(randomGroup, GroupType.Horizontal, LEFT);
  // Radio buttons
  const radioNormal = musicPlayerUI.addRadioButton(Name.No_Random, randomGroup, Description.No_Random);
  const radioAllRandom = musicPlayerUI.addRadioButton(Name.All_Random, randomGroup, Description.All_Random);
  const radioCurrentRandom = musicPlayerUI.addRadioButton(Name.Current_Random, randomGroup, Description.Current_Random);
  radioNormal.addEventListener("click", (_e) => (musicSettings.randomThemesType = RandomThemeType.NONE));
  radioAllRandom.addEventListener("click", (_e) => (musicSettings.randomThemesType = RandomThemeType.ALL_THEMES));
  radioCurrentRandom.addEventListener(
    "click",
    (_e) => (musicSettings.randomThemesType = RandomThemeType.CURRENT_SOUNDTRACK),
  );
  const randomRadioMap = new Map([
    [RandomThemeType.NONE, radioNormal],
    [RandomThemeType.ALL_THEMES, radioAllRandom],
    [RandomThemeType.CURRENT_SOUNDTRACK, radioCurrentRandom],
  ]);
  // Random theme shuffle button
  const shuffleBtn = musicPlayerUI.addButton(Name.Shuffle, randomGroup, Description.Shuffle);
  shuffleBtn.addEventListener("click", (_e) => musicSettings.randomizeCO());
  /* **** Group: Sound effect toggle checkboxes **** */
  const toggleGroup = "Extra Options";
  musicPlayerUI.addGroup(toggleGroup, GroupType.Vertical, LEFT);
  // Checkboxes
  const captProgressBox = musicPlayerUI.addCheckbox(Name.Capture_Progress, toggleGroup, Description.Capture_Progress);
  const pipeSeamBox = musicPlayerUI.addCheckbox(Name.Pipe_Seam_SFX, toggleGroup, Description.Pipe_Seam_SFX);
  const restartThemesBox = musicPlayerUI.addCheckbox(Name.Restart_Themes, toggleGroup, Description.Restart_Themes);
  const autoplayPagesBox = musicPlayerUI.addCheckbox(Name.Autoplay_Pages, toggleGroup, Description.Autoplay_Pages);
  const alternateThemesBox = musicPlayerUI.addCheckbox(
    Name.Alternate_Themes,
    toggleGroup,
    Description.Alternate_Themes,
  );
  captProgressBox.addEventListener("click", (_e) => (musicSettings.captureProgressSFX = captProgressBox.checked));
  pipeSeamBox.addEventListener("click", (_e) => (musicSettings.pipeSeamSFX = pipeSeamBox.checked));
  restartThemesBox.addEventListener("click", (_e) => (musicSettings.restartThemes = restartThemesBox.checked));
  autoplayPagesBox.addEventListener("click", (_e) => (musicSettings.autoplayOnOtherPages = autoplayPagesBox.checked));
  alternateThemesBox.addEventListener("click", (_e) => (musicSettings.alternateThemes = alternateThemesBox.checked));
  /* **** Group: Day slider **** */
  const daySlider = musicPlayerUI.addSlider(Name.Alternate_Day, 0, 30, 1, Description.Alternate_Day, LEFT);
  daySlider?.addEventListener("input", (event) => (musicSettings.alternateThemeDay = parseInputInt(event)));
  /* ************************************ Right Menu ************************************ */
  const RIGHT = MenuPosition.Right;
  /* **** Group: Override Themes **** */
  const addOverrideGroup = "Override Themes";
  musicPlayerUI.addGroup(addOverrideGroup, GroupType.Horizontal, RIGHT);
  // CO selector
  let currentSelectedCO = "andy";
  function onCOSelectorClick(coName) {
    currentSelectedCO = coName;
  }
  if (isGamePageAndActive()) musicPlayerUI.addCOSelector(addOverrideGroup, Description.Add_Override, onCOSelectorClick);
  // Game type radio buttons
  const overrideGameTypeRadioMap = new Map();
  for (const gameType of Object.values(GameType)) {
    const radio = musicPlayerUI.addRadioButton(gameType, addOverrideGroup, Description.Override_Radio + gameType);
    overrideGameTypeRadioMap.set(gameType, radio);
    radio.checked = true;
  }
  const excludeRadio = musicPlayerUI.addRadioButton("Exclude Random", addOverrideGroup, Description.Add_Excluded);
  // Add override button
  const overrideBtn = musicPlayerUI.addButton(Name.Add_Override, addOverrideGroup, Description.Add_Override);
  overrideBtn.addEventListener("click", (_e) => {
    // Check if it's an exclude
    if (excludeRadio.checked) {
      musicSettings.addExcludedRandomTheme(currentSelectedCO);
      return;
    }
    // Get the selected game type
    let currentGameType;
    for (const [gameType, radio] of overrideGameTypeRadioMap) {
      if (radio.checked) currentGameType = gameType;
    }
    // Add the override
    if (!currentGameType) return;
    musicSettings.addOverride(currentSelectedCO, currentGameType);
  });
  /* **** Group: Override List **** */
  const overrideListGroup = "Current Overrides (Click to Remove)";
  musicPlayerUI.addGroup(overrideListGroup, GroupType.Horizontal, RIGHT);
  const overrideDivMap = new Map();
  const tableRows = 4;
  const tableCols = 7;
  musicPlayerUI.addTable(Name.Override_Table, tableRows, tableCols, overrideListGroup, Description.Remove_Override);
  function addOverrideDisplayDiv(coName, gameType) {
    const displayDiv = musicPlayerUI.createCOPortraitImageWithText(coName, gameType);
    displayDiv.addEventListener("click", (_event) => {
      musicSettings.removeOverride(coName);
    });
    overrideDivMap.set(coName, displayDiv);
    musicPlayerUI.addItemToTable(Name.Override_Table, displayDiv);
    return displayDiv;
  }
  function clearAndRepopulateOverrideList() {
    overrideDivMap.forEach((div) => div.remove());
    overrideDivMap.clear();
    musicPlayerUI.clearTable(Name.Override_Table);
    for (const [coName, gameType] of musicSettings.overrideList) {
      addOverrideDisplayDiv(coName, gameType);
    }
  }
  /* **** Group: Not Randomized List **** */
  const excludedListGroup = "Themes Excluded From Randomizer (Click to Remove)";
  musicPlayerUI.addGroup(excludedListGroup, GroupType.Horizontal, RIGHT);
  const excludedListDivMap = new Map();
  musicPlayerUI.addTable(Name.Excluded_Table, tableRows, tableCols, excludedListGroup, Description.Remove_Override);
  function addExcludedDisplayDiv(coName) {
    const displayDiv = musicPlayerUI.createCOPortraitImageWithText(coName, "");
    displayDiv.addEventListener("click", (_event) => {
      musicSettings.removeExcludedRandomTheme(coName);
    });
    excludedListDivMap.set(coName, displayDiv);
    musicPlayerUI.addItemToTable(Name.Excluded_Table, displayDiv);
    return displayDiv;
  }
  function clearAndRepopulateExcludedList() {
    excludedListDivMap.forEach((div) => div.remove());
    excludedListDivMap.clear();
    musicPlayerUI.clearTable(Name.Excluded_Table);
    for (const coName of musicSettings.excludedRandomThemes) addExcludedDisplayDiv(coName);
  }
  /* ************************************ Version ************************************ */
  musicPlayerUI.addVersion(versions.music_player);
  /* ************************************ Disable or hide things in other pages ************************************ */
  if (!isGamePageAndActive()) {
    const parent = musicPlayerUI.getGroup("settings-parent");
    if (parent) parent.style.width = "475px";
    const rightGroup = musicPlayerUI.getGroup(RIGHT);
    if (rightGroup) rightGroup.style.display = "none";
    if (captProgressBox?.parentElement) captProgressBox.parentElement.style.display = "none";
    if (pipeSeamBox?.parentElement) pipeSeamBox.parentElement.style.display = "none";
    if (restartThemesBox?.parentElement) restartThemesBox.parentElement.style.display = "none";
    if (alternateThemesBox?.parentElement) alternateThemesBox.parentElement.style.display = "none";
    if (daySlider?.parentElement) daySlider.parentElement.style.display = "none";
    if (!isMapEditor() && !isMaintenance()) {
      if (soundtrackGroupDiv?.parentElement) soundtrackGroupDiv.parentElement.style.display = "none";
      if (randomGroupDiv?.parentElement) randomGroupDiv.parentElement.style.display = "none";
    }
  }

  /**
   * @file IndexedDB database for caching music files.
   */
  /**
   * The IndexedDB database for caching music files.
   */
  let db = null;
  /**
   * The name of the database.
   */
  const dbName = "awbw_music_player";
  /**
   * The version of the database.
   * This should be incremented whenever the database schema changes.
   */
  const dbVersion = 1.0;
  /**
   * A set of URLs that are queued to be stored in the database.
   * This is used to prevent storing the same URL multiple times while waiting for promises.
   */
  const urlQueue$1 = new Set();
  /**
   * A set of listeners that are called when a music file is replaced in the database.
   */
  const replacementListeners = new Set();
  /**
   * Adds a listener that is called when a music file is replaced in the database.
   * @param fn - The listener to add
   */
  function addDatabaseReplacementListener(fn) {
    replacementListeners.add(fn);
  }
  /**
   * Opens the IndexedDB database for caching music files.
   * @param onOpenOrError - Optional callback for when the database is opened or an error occurs when opening it.
   */
  function openDB() {
    const request = indexedDB.open(dbName, dbVersion);
    return new Promise((resolve, reject) => {
      request.onerror = (event) => reject(event);
      request.onupgradeneeded = (event) => {
        if (!event.target) return reject("No target for database upgrade.");
        // logDebug("Database upgrade needed. Creating object store.");
        const newDB = event.target.result;
        newDB.createObjectStore("music");
      };
      request.onsuccess = (event) => {
        if (!event.target) return reject("No target for database success.");
        db = event.target.result;
        db.onerror = (event) => {
          reject(`Error accessing database: ${event}`);
        };
        resolve();
      };
    });
  }
  /**
   * Attempts to load the music file at the given URL from the database.
   * @param srcURL - The URL of the music file to load
   * @returns - A promise that resolves with the URL of the local music file, or rejects with a reason
   */
  function loadMusicFromDB(srcURL) {
    if (!srcURL || srcURL === "") return Promise.reject("Invalid URL.");
    if (urlQueue$1.has(srcURL)) return Promise.reject("URL is already queued for storage.");
    urlQueue$1.add(srcURL);
    return new Promise((resolve, reject) => {
      // If the database is not open, just fallback to the original URL
      if (!db) return reject("Database is not open.");
      const transaction = db.transaction("music", "readonly");
      const store = transaction.objectStore("music");
      const request = store.get(srcURL);
      request.onsuccess = (event) => {
        urlQueue$1.delete(srcURL);
        // The music file is not in the database, wait for it to be stored and return the new blob URL
        const blob = event.target.result;
        if (!blob) {
          return storeURLInDB(srcURL)
            .then((blob) => resolve(URL.createObjectURL(blob)))
            .catch((reason) => reject(reason));
        }
        const url = URL.createObjectURL(blob);
        resolve(url);
      };
      request.onerror = (event) => {
        urlQueue$1.delete(srcURL);
        reject(event);
      };
    });
  }
  /**
   * Stores the given blob in the database with the given URL.
   * @param url - The URL to store the blob under
   * @param blob - The blob to store
   */
  function storeBlobInDB(url, blob) {
    return new Promise((resolve, reject) => {
      if (!db) return reject("Database not open.");
      if (!url || url === "") return reject("Invalid URL.");
      const transaction = db.transaction("music", "readwrite");
      const store = transaction.objectStore("music");
      const request = store.put(blob, url);
      request.onsuccess = () => {
        resolve(blob);
        replacementListeners.forEach((fn) => fn(url));
      };
      request.onerror = (event) => reject(event);
    });
  }
  /**
   * Stores the music file at the given URL in the database.
   * @param url - The URL of the music file to store
   */
  function storeURLInDB(url) {
    if (!db) return Promise.reject("Database not open.");
    if (!url || url === "") return Promise.reject("Invalid URL.");
    return fetch(url)
      .then((response) => response.blob())
      .then((blob) => storeBlobInDB(url, blob));
    // .catch((reason) => logError("Error fetching music file to store in database:", reason));
  }
  /**
   * Compares the hashes of the music files stored in the database against the hashes stored on the server.
   * If a hash is different, the music file is replaced in the database.
   * @returns - A promise that resolves when the hashes have been compared
   */
  function checkHashesInDB() {
    if (!db) return Promise.reject("Database not open.");
    // Get the hashes stored in the server
    // logDebug("Fetching hashes from server to compare against local music files.");
    return fetch(HASH_JSON_URL)
      .then((response) => response.json())
      .then((hashes) => compareHashesAndReplaceIfNeeded(hashes));
    // .catch((reason) => logError("Error fetching hashes from server:", reason));
  }
  /**
   * Calculates the MD5 hash of the given blob.
   * @param blob - The blob to calculate the hash of
   * @returns - A promise that resolves with the MD5 hash of the blob
   */
  function getBlobMD5(blob) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = (event) => {
        if (!event?.target?.result) return reject("FileReader did not load the blob.");
        const md5 = SparkMD5.ArrayBuffer.hash(event.target.result);
        resolve(md5);
      };
      reader.onerror = (event) => reject(event);
      reader.readAsArrayBuffer(blob);
    });
  }
  /**
   * Compares the hashes of the music files stored in the database against the json object of hashes provided.
   * @param hashesJson - The JSON object of hashes to compare against.
   */
  function compareHashesAndReplaceIfNeeded(hashesJson) {
    return new Promise((resolve, reject) => {
      if (!db) return reject("Database not open.");
      if (!hashesJson) return reject("No hashes found in server.");
      // Get all the blobs stored in the database
      const transaction = db.transaction("music", "readonly");
      const store = transaction.objectStore("music");
      const request = store.openCursor();
      request.onerror = (event) => reject(event);
      request.onsuccess = (event) => {
        const cursor = event.target.result;
        // All entries have been checked
        if (!cursor) return resolve();
        const url = cursor.key;
        const blob = cursor.value;
        const serverHash = hashesJson[url];
        cursor.continue();
        // logDebug("Checking hash for", url);
        if (!serverHash) {
          logDebug("No hash found in server for", url);
          return;
        }
        getBlobMD5(blob)
          .then((hash) => {
            if (hash === serverHash) return;
            // The hash is different, so we need to replace the song
            return storeURLInDB(url);
          })
          .catch((reason) => logError(`Error storing new version of ${url} in database: ${reason}`));
      };
    });
  }

  /**
   * @file All the music-related functions for the music player.
   */
  // Type definitions for Howler
  // Until howler gets modernized (https://github.com/goldfire/howler.js/pull/1518)
  /**
   * The URL of the current theme that is playing.
   */
  let currentThemeKey = "";
  /**
   * Map containing the audio players for all themes and SFX.
   * The keys are the audio URLs.
   */
  const audioMap = new Map();
  /**
   * Set of URLs that are queued to be pre-loaded.
   * This is used to prevent pre-loading the same URL multiple times while waiting for promises.
   */
  const urlQueue = new Set();
  /**
   * Map containing the audio players for all units.
   * The keys are the unit IDs.
   */
  const unitIDAudioMap = new Map();
  /**
   * Map containing the special loop URLs for themes that have them. These get added after the original theme ends.
   * The keys are the original theme URLs.
   * The values are the special loop URLs to play after the original theme ends.
   */
  const specialLoopMap = new Map();
  /**
   * Number of loops that the current theme has done.
   */
  let currentLoops = 0;
  /**
   * If set to true, calls to playMusic() will set a timer for {@link delayThemeMS} milliseconds after which the music will play again.
   */
  let currentlyDelaying = false;
  // Listen for setting changes to update the internal variables accordingly
  addSettingsChangeListener(onSettingsChange);
  // Listens for when the database downloads a new song
  addDatabaseReplacementListener((url) => {
    const audio = audioMap.get(url);
    if (!audio) return;
    // Song update due to hash change
    log("A new version of", url, " is available. Replacing the old version.");
    if (audio.playing()) audio.stop();
    urlQueue.delete(url);
    audioMap.delete(url);
    preloadURL(url)
      .then(playThemeSong)
      .catch((reason) => logError(reason));
  });
  /**
   * Event handler that pauses an audio as soon as it gets loaded.
   * @param event - The event that triggered this handler. Usually "canplaythrough".
   */
  function whenAudioLoadsPauseIt(event) {
    event.target.pause();
  }
  /**
   * Event handler that gets called when a theme ends or loops.
   * @param srcURL - URL of the theme that ended or looped.
   */
  function onThemeEndOrLoop(srcURL) {
    currentLoops++;
    if (currentThemeKey !== srcURL) {
      logError("Playing more than one theme at a time! Please report this bug!", srcURL);
      return;
    }
    // The song has a special loop, so mark it in the special loop map as having done one loop
    if (hasSpecialLoop(srcURL)) {
      const loopURL = srcURL.replace(".ogg", "-loop.ogg");
      specialLoopMap.set(srcURL, loopURL);
      playThemeSong();
    }
    if (
      srcURL === "https://developerjose.netlify.app/music/t-victory.ogg" /* SpecialTheme.Victory */ ||
      srcURL === "https://developerjose.netlify.app/music/t-defeat.ogg" /* SpecialTheme.Defeat */
    ) {
      if (currentLoops >= 5)
        playMusicURL("https://developerjose.netlify.app/music/t-co-select.ogg" /* SpecialTheme.COSelect */);
    }
    // The song ended and we are playing random themes, so switch to the next random theme
    if (musicSettings.randomThemesType !== RandomThemeType.NONE) {
      musicSettings.randomizeCO();
      playThemeSong();
    }
  }
  /**
   * Event handler that gets called when a theme starts playing.
   * @param audio - The audio player that started playing.
   * @param srcURL - URL of the theme that started playing.
   */
  function onThemePlay(audio, srcURL) {
    currentLoops = 0;
    audio.volume(getVolumeForURL(srcURL));
    // We start from the beginning if any of these conditions are met:
    // 1. The user wants to restart themes
    // 2. It's a power theme
    // 3. We are starting a new random theme
    // AND we are on the game page AND the song has played for a bit
    const isPowerTheme = musicSettings.themeType !== ThemeType.REGULAR;
    const isRandomTheme = musicSettings.randomThemesType !== RandomThemeType.NONE;
    const shouldRestart = musicSettings.restartThemes || isPowerTheme || isRandomTheme;
    const currentPosition = audio.seek();
    if (shouldRestart && isGamePageAndActive() && currentPosition > 0.1) {
      // logDebug("Restart2", shouldRestart, currentPosition);
      audio.seek(0);
    }
    // The current theme is not this one, so pause this one and let the other one play
    // This check makes sure we aren't playing more than one song at the same time
    if (currentThemeKey !== srcURL && audio.playing()) {
      audio.pause();
      playThemeSong();
    }
  }
  /**
   * Pre-loads the audio from the given URL and returns a promise that resolves with an audio player.
   * If the audio is not in the database, it will be loaded from the original URL.
   * @param srcURL - URL of the audio to preload.
   * @returns - Promise that resolves with the audio player of the audio in the database or the original URL.
   */
  function preloadURL(srcURL) {
    // Someone already tried to preload this audio
    if (urlQueue.has(srcURL)) return Promise.reject(`Cannot preload ${srcURL}, it is already queued for pre-loading.`);
    urlQueue.add(srcURL);
    // We already have this audio loaded
    if (audioMap.has(srcURL)) return Promise.reject(`Cannot preload ${srcURL}, it is already pre-loaded.`);
    // Preload the audio from the database if possible
    // logDebug("Loading new song", srcURL);
    return loadMusicFromDB(srcURL).then(
      (localCacheURL) => createNewAudio(srcURL, localCacheURL),
      (reason) => {
        logDebug(reason, srcURL);
        return createNewAudio(srcURL, srcURL);
      },
    );
    /**
     * Creates a new audio player for the given URL.
     * @param srcURL - URL of the audio to create a player for.
     * @returns - The new audio player.
     */
    function createNewAudio(srcURL, cacheURL) {
      const audioInMap = audioMap.get(srcURL);
      if (audioInMap !== undefined) {
        logError("Race Condition! Please report this bug!", srcURL);
        return audioInMap;
      }
      // logDebug("Creating new audio player for:", srcURL, cacheURL);
      // Shared audio settings for all audio players
      const audio = new Howl({
        src: [cacheURL],
        format: ["ogg"],
        // Redundant event listeners to ensure the audio is always at the correct volume
        onplay: (_id) => audio.volume(getVolumeForURL(audio._src)),
        onload: (_id) => audio.volume(getVolumeForURL(audio._src)),
        onseek: (_id) => audio.volume(getVolumeForURL(audio._src)),
        onpause: (_id) => audio.volume(getVolumeForURL(audio._src)),
        onloaderror: (_id, error) => logError("Error loading audio:", srcURL, error),
        onplayerror: (_id, error) => logError("Error playing audio:", srcURL, error),
      });
      audioMap.set(srcURL, audio);
      // Sound Effects
      if (srcURL.includes("sfx")) {
        audio.volume(srcURL.includes("ui") ? musicSettings.uiVolume : musicSettings.sfxVolume);
        return audio;
      }
      // Themes
      audio.volume(getVolumeForURL(srcURL));
      audio.on("play", () => onThemePlay(audio, srcURL));
      audio.on("load", () => playThemeSong());
      audio.on("end", () => onThemeEndOrLoop(srcURL));
      return audio;
    }
  }
  /**
   * Changes the current song to the given new song, stopping the old song if necessary.
   * @param srcURL - URL of song to play.
   * @param startFromBeginning - Whether to start from the beginning.
   */
  function playMusicURL(srcURL) {
    // This song has a special loop, and it's time to play it
    const specialLoopURL = specialLoopMap.get(srcURL);
    if (specialLoopURL) srcURL = specialLoopURL;
    // We want to play a new song, so pause the previous one and save the new current song
    if (srcURL !== currentThemeKey) {
      stopThemeSong();
      currentThemeKey = srcURL;
    }
    // The song isn't loaded yet, so create a new audio player for it
    if (!audioMap.has(srcURL)) {
      // No one else is preloading this audio, so preload it
      if (!urlQueue.has(srcURL)) preloadURL(srcURL).catch((reason) => logError(reason));
      return;
    }
    const nextSong = audioMap.get(srcURL);
    if (!nextSong) return;
    // Loop all themes except for the special ones
    nextSong.loop(!hasSpecialLoop(srcURL));
    nextSong.volume(getVolumeForURL(srcURL));
    // Play the song if it's not already playing
    if (!nextSong.playing() && musicSettings.isPlaying) {
      log("Now Playing: ", srcURL, " | Cached? =", nextSong._src !== srcURL);
      nextSong.play();
    }
  }
  /**
   * Plays the given sound by creating a new instance of it.
   * @param srcURL - URL of the sound to play.
   * @param volume - Volume at which to play this sound.
   */
  function playOneShotURL(srcURL, volume) {
    if (!musicSettings.isPlaying) return;
    const soundInstance = new Audio(srcURL);
    soundInstance.currentTime = 0;
    soundInstance.volume = volume;
    soundInstance.play();
  }
  /**
   * Plays the appropriate music based on the settings and the current game state.
   * Determines the music automatically so just call this anytime the game state changes.
   * @param startFromBeginning - Whether to start the song from the beginning or resume from the previous spot.
   */
  function playThemeSong() {
    if (!musicSettings.isPlaying) return;
    // Someone wants us to delay playing the theme, so wait a little bit then play
    // Ignore all calls to play() while delaying, we are guaranteed to play eventually
    if (currentlyDelaying) return;
    let gameType = undefined;
    let coName = currentPlayer.coName;
    // Don't randomize the victory and defeat themes
    const isEndTheme = coName === "victory" || coName === "defeat";
    const isRandomTheme = musicSettings.randomThemesType !== RandomThemeType.NONE;
    if (isRandomTheme && !isEndTheme) {
      coName = musicSettings.currentRandomCO;
      // The user wants the random themes from all soundtracks, so randomize the game type
      if (musicSettings.randomThemesType === RandomThemeType.ALL_THEMES) gameType = musicSettings.currentRandomGameType;
    }
    // For pages with no COs that aren't using the random themes, play the stored theme if any.
    if (!coName) {
      if (!currentThemeKey || currentThemeKey === "") return;
      playMusicURL(currentThemeKey);
      return;
    }
    playMusicURL(getMusicURL(coName, gameType));
  }
  /**
   * Stops the current music if there's any playing.
   * Optionally, you can also delay the start of the next theme.
   * @param delayMS - Time to delay before we start the next theme.
   */
  function stopThemeSong(delayMS = 0) {
    // Delay the next theme if needed
    if (delayMS > 0) {
      // Delay until I say so
      window.setTimeout(() => {
        currentlyDelaying = false;
        playThemeSong();
      }, delayMS);
      currentlyDelaying = true;
    }
    // Can't stop if there's no loaded music
    if (!audioMap.has(currentThemeKey)) return;
    // Can't stop if we are already paused
    const currentTheme = audioMap.get(currentThemeKey);
    if (!currentTheme) return;
    // The song is loaded and playing, so pause it
    logDebug("Pausing: ", currentThemeKey);
    currentTheme.pause();
  }
  /**
   * Plays the movement sound of the given unit.
   * @param unitId - The ID of the unit who is moving.
   */
  function playMovementSound(unitId) {
    if (!musicSettings.isPlaying) return;
    // The audio hasn't been preloaded for this unit
    if (!unitIDAudioMap.has(unitId)) {
      const unitName = getUnitName(unitId);
      if (!unitName) return;
      const movementSoundURL = getMovementSoundURL(unitName);
      unitIDAudioMap.set(unitId, new Audio(movementSoundURL));
    }
    // Restart the audio and then play it
    const movementAudio = unitIDAudioMap.get(unitId);
    if (!movementAudio) return;
    movementAudio.currentTime = 0;
    movementAudio.loop = false;
    movementAudio.volume = musicSettings.sfxVolume;
    movementAudio.play();
  }
  /**
   * Stops the movement sound of a given unit if it's playing.
   * @param unitId - The ID of the unit whose movement sound will be stopped.
   * @param rolloff - (Optional) Whether to play the rolloff sound or not, defaults to true.
   */
  function stopMovementSound(unitId, rolloff = true) {
    // Can't stop if there's nothing playing
    if (!musicSettings.isPlaying) return;
    // Can't stop if the unit doesn't have any sounds
    if (!unitIDAudioMap.has(unitId)) return;
    // Can't stop if the sound is already stopped
    const movementAudio = unitIDAudioMap.get(unitId);
    if (!movementAudio || movementAudio.paused) return;
    // The audio hasn't finished loading, so pause when it does
    if (movementAudio.readyState != HTMLAudioElement.prototype.HAVE_ENOUGH_DATA) {
      movementAudio.addEventListener("canplaythrough", whenAudioLoadsPauseIt, { once: true });
      return;
    }
    // The audio is loaded and playing, so pause it
    movementAudio.pause();
    movementAudio.currentTime = 0;
    // If unit has rolloff, play it
    const unitName = getUnitName(unitId);
    if (!rolloff || !unitName) return;
    if (hasMovementRollOff(unitName)) {
      const audioURL = getMovementRollOffURL(unitName);
      playOneShotURL(audioURL, musicSettings.sfxVolume);
    }
  }
  /**
   * Plays the given sound effect.
   * @param sfx - Specific {@link GameSFX} to play.
   */
  function playSFX(sfx) {
    if (!musicSettings.isPlaying) return;
    // Check the user settings to see if we should play this sound effect
    if (!musicSettings.captureProgressSFX && sfx === GameSFX.unitCaptureProgress) return;
    if (!musicSettings.pipeSeamSFX && sfx === GameSFX.unitAttackPipeSeam) return;
    const sfxURL = getSoundEffectURL(sfx);
    // This sound effect hasn't been loaded yet
    if (!audioMap.has(sfxURL)) {
      preloadURL(sfxURL)
        .then(() => playSFX(sfx))
        .catch((reason) => logError(reason));
      return;
    }
    // The sound is loaded, so play it
    const audio = audioMap.get(sfxURL);
    if (!audio) return;
    audio.volume(getVolumeForURL(sfxURL));
    audio.seek(0);
    audio.play();
  }
  /**
   * Stops all music, sound effects, and audios.
   */
  function stopAllSounds() {
    // Stop current music
    stopThemeSong();
    // Stop unit sounds
    stopAllMovementSounds();
    // Stop all other music, just for redundancy
    for (const audio of audioMap.values()) {
      if (audio.playing()) audio.pause();
    }
  }
  /**
   * Stops all movement sounds of all units.
   */
  function stopAllMovementSounds() {
    for (const unitId of unitIDAudioMap.keys()) {
      stopMovementSound(unitId, false);
    }
  }
  /**
   * Preloads the current game COs' themes and common sound effect audios.
   * Run this first so we can start the player almost immediately!
   * @param afterPreloadFunction - Function to run after the audio is pre-loaded.
   */
  function preloadAllCommonAudio(afterPreloadFunction) {
    // Preload the themes of the COs in this match
    const audioList = getCurrentThemeURLs();
    // Preload the most common UI sounds that might play right after the page loads
    audioList.add(getSoundEffectURL(GameSFX.uiCursorMove));
    audioList.add(getSoundEffectURL(GameSFX.uiUnitSelect));
    logDebug("Pre-loading common audio", audioList);
    preloadAudioList(audioList, afterPreloadFunction);
  }
  /**
   * Preloads the given list of songs and adds them to the {@link urlAudioMap}.
   * @param audioURLs - Set of URLs of songs to preload.
   * @param afterPreloadFunction - Function to call after all songs are preloaded.
   */
  function preloadAudioList(audioURLs, afterPreloadFunction = () => {}) {
    // Event handler for when an audio is loaded
    let numLoadedAudios = 0;
    const onAudioPreload = (action, url) => {
      numLoadedAudios++;
      // Update UI
      const loadPercentage = (numLoadedAudios / audioURLs.size) * 100;
      musicPlayerUI.setProgress(loadPercentage);
      // All the audio from the list has been loaded
      if (numLoadedAudios >= audioURLs.size) {
        numLoadedAudios = 0;
        if (afterPreloadFunction) afterPreloadFunction();
      }
      if (action === "error") {
        log(`Could not pre-load: ${url}. This might not be a problem, the audio may still play normally later.`);
        audioMap.delete(url);
        return;
      }
      // TODO: Debugging purposes
      // if (hasSpecialLoop(audio.src)) audio.currentTime = audio.duration * 0.94;
      if (!audioMap.has(url)) {
        logError("Race condition on pre-load! Please report this bug!", url);
      }
    };
    // Pre-load all audios in the list
    audioURLs.forEach((url) => {
      // This audio has already been loaded before, so skip it
      if (audioMap.has(url)) {
        numLoadedAudios++;
        return;
      }
      // Try to get the audio from the cache, if not, load it from the original URL
      preloadURL(url)
        .then((audio) => {
          audio.once("load", () => onAudioPreload("load", url));
          audio.once("loaderror", () => onAudioPreload("error", url));
        })
        .catch((_reason) => onAudioPreload("error", url));
    });
  }
  /**
   * Gets the volume for the given URL based on the type of audio it is.
   * @param url - URL of the audio to get the volume for.
   * @returns - The volume to play the audio at.
   */
  function getVolumeForURL(url) {
    if (url.includes("sfx")) {
      if (url.includes("ui")) return musicSettings.uiVolume;
      if (url.includes("power")) return musicSettings.volume;
      return musicSettings.sfxVolume;
    }
    return musicSettings.volume;
  }
  /**
   * Adds event listeners to play or pause the music when the window focus changes.
   */
  function playOrPauseWhenWindowFocusChanges() {
    window.addEventListener("blur", () => {
      if (musicSettings.isPlaying) stopAllSounds();
    });
    window.addEventListener("focus", () => {
      if (musicSettings.isPlaying) playThemeSong();
    });
  }
  /**
   * Updates the internal audio components to match the current music player settings when the settings change.
   * @param key - Key of the setting which has been changed.
   * @param isFirstLoad - Whether this is the first time the settings are being loaded.
   */
  function onSettingsChange(key, isFirstLoad) {
    // Don't do anything if this is the first time the settings are being loaded
    if (isFirstLoad) return;
    switch (key) {
      case SettingsKey.ADD_OVERRIDE:
      case SettingsKey.REMOVE_OVERRIDE:
      case SettingsKey.OVERRIDE_LIST:
      case SettingsKey.CURRENT_RANDOM_CO:
      case SettingsKey.IS_PLAYING:
        // case "restartThemes":
        if (musicSettings.isPlaying) {
          playThemeSong();
        } else {
          stopAllSounds();
        }
        break;
      case SettingsKey.GAME_TYPE:
      case SettingsKey.ALTERNATE_THEME_DAY:
      case SettingsKey.ALTERNATE_THEMES:
        window.setTimeout(() => playThemeSong(), 500);
        break;
      case SettingsKey.THEME_TYPE: {
        // const restartMusic = musicSettings.themeType !== SettingsThemeType.REGULAR;
        playThemeSong();
        break;
      }
      case SettingsKey.REMOVE_EXCLUDED:
        if (musicSettings.excludedRandomThemes.size === 27) {
          musicSettings.randomizeCO();
        }
        playThemeSong();
        break;
      case SettingsKey.EXCLUDED_RANDOM_THEMES:
      case SettingsKey.ADD_EXCLUDED:
        if (musicSettings.excludedRandomThemes.has(musicSettings.currentRandomCO)) {
          musicSettings.randomizeCO();
        }
        playThemeSong();
        break;
      case SettingsKey.RANDOM_THEMES_TYPE: {
        // Back to normal themes
        const randomThemes = musicSettings.randomThemesType !== RandomThemeType.NONE;
        if (!randomThemes) {
          playThemeSong();
          return;
        }
        // We want a new random theme
        musicSettings.randomizeCO();
        playThemeSong();
        break;
      }
      case SettingsKey.VOLUME: {
        // Adjust the volume of the current theme
        const currentTheme = audioMap.get(currentThemeKey);
        if (currentTheme) currentTheme.volume(musicSettings.volume);
        // Adjust the volume once we can
        if (!currentTheme) {
          const intervalID = window.setInterval(() => {
            const currentTheme = audioMap.get(currentThemeKey);
            if (currentTheme) {
              currentTheme.volume(musicSettings.volume);
              clearInterval(intervalID);
            }
          });
        }
        // Adjust all theme volumes
        for (const audio of audioMap.values()) {
          audio.volume(getVolumeForURL(audio._src));
        }
        break;
      }
    }
  }

  /**
   * @file Functions used by Advance Wars By Web to handle game actions.
   */
  // export function getCursorMoveFn() {
  //   if (getIsMapEditor()) {
  //     return typeof designMapEditor !== "undefined" ? designMapEditor.updateCursor : null;
  //   }
  //   return typeof updateCursor !== "undefined" ? updateCursor : null;
  // }
  function getQueryTurnFn() {
    return typeof queryTurn !== "undefined" ? queryTurn : null;
  }
  function getShowEventScreenFn() {
    return typeof showEventScreen !== "undefined" ? showEventScreen : null;
  }
  function getShowEndGameScreenFn() {
    return typeof showEndGameScreen !== "undefined" ? showEndGameScreen : null;
  }
  function getOpenMenuFn() {
    return typeof openMenu !== "undefined" ? openMenu : null;
  }
  function getCloseMenuFn() {
    return typeof closeMenu !== "undefined" ? closeMenu : null;
  }
  function getCreateDamageSquaresFn() {
    return typeof createDamageSquares !== "undefined" ? createDamageSquares : null;
  }
  function getUnitClickFn() {
    return typeof unitClickHandler !== "undefined" ? unitClickHandler : null;
  }
  function getWaitFn() {
    return typeof waitUnit !== "undefined" ? waitUnit : null;
  }
  function getAnimUnitFn() {
    return typeof animUnit !== "undefined" ? animUnit : null;
  }
  function getAnimExplosionFn() {
    return typeof animExplosion !== "undefined" ? animExplosion : null;
  }
  function getFogFn() {
    return typeof updateAirUnitFogOnMove !== "undefined" ? updateAirUnitFogOnMove : null;
  }
  function getFireFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Fire : null;
  }
  function getAttackSeamFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.AttackSeam : null;
  }
  function getMoveFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Move : null;
  }
  function getCaptFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Capt : null;
  }
  function getBuildFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Build : null;
  }
  function getLoadFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Load : null;
  }
  function getUnloadFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Unload : null;
  }
  function getSupplyFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Supply : null;
  }
  function getRepairFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Repair : null;
  }
  function getHideFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Hide : null;
  }
  function getUnhideFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Unhide : null;
  }
  function getJoinFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Join : null;
  }
  function getLaunchFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Launch : null;
  }
  function getNextTurnFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.NextTurn : null;
  }
  function getEliminationFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Elimination : null;
  }
  function getPowerFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Power : null;
  }
  function getGameOverFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.GameOver : null;
  }
  function getResignFn() {
    return typeof actionHandlers !== "undefined" ? actionHandlers.Resign : null;
  }

  /**
   * @file This file contains all the AWBW website handlers that will intercept clicks and any relevant functions of the website.
   */
  /**
   * How long to wait in milliseconds before we register a cursor movement.
   * Used to prevent overwhelming the user with too many cursor movement sound effects.
   * @constant
   */
  const CURSOR_THRESHOLD_MS = 25;
  /**
   * Date representing when we last moved the game cursor.
   */
  let lastCursorCall = Date.now();
  /**
   * The last known X coordinate of the cursor.
   */
  let lastCursorX = -1;
  /**
   * The last known Y coordinate of the cursor.
   */
  let lastCursorY = -1;
  /**
   * Enum representing the type of menu that is currently open, if any.
   * @enum {string}
   */
  var MenuOpenType;
  (function (MenuOpenType) {
    MenuOpenType["None"] = "None";
    MenuOpenType["DamageSquare"] = "DamageSquare";
    MenuOpenType["Regular"] = "Regular";
    MenuOpenType["UnitSelect"] = "UnitSelect";
  })(MenuOpenType || (MenuOpenType = {}));
  /**
   * The current type of menu that is open, if any.
   */
  let currentMenuType = MenuOpenType.None;
  /**
   * Map of unit IDs to their visibility status. Used to check if a unit that was visible disappeared in the fog.
   */
  const visibilityMap = new Map();
  /**
   * Map of unit IDs to their movement responses. Used to check if a unit got trapped.
   */
  const movementResponseMap = new Map();
  /**
   * Map of damage squares that have been clicked.
   * Used to check if the user clicked on a damage square twice to finalize an attack.
   */
  const clickedDamageSquaresMap = new Map();
  /* **Store a copy of all the original functions we are going to override** */
  const ahQueryTurn = getQueryTurnFn();
  const ahShowEventScreen = getShowEventScreenFn();
  const ahShowEndGameScreen = getShowEndGameScreenFn();
  // let ahSwapCosDisplay = getSwapCosDisplayFn();
  const ahOpenMenu = getOpenMenuFn();
  const ahCloseMenu = getCloseMenuFn();
  const ahCreateDamageSquares = getCreateDamageSquaresFn();
  // let ahResetAttack = getResetAttackFn();
  const ahUnitClick = getUnitClickFn();
  const ahWait = getWaitFn();
  const ahAnimUnit = getAnimUnitFn();
  const ahAnimExplosion = getAnimExplosionFn();
  const ahFog = getFogFn();
  const ahFire = getFireFn();
  const ahAttackSeam = getAttackSeamFn();
  const ahMove = getMoveFn();
  const ahCapt = getCaptFn();
  const ahBuild = getBuildFn();
  const ahLoad = getLoadFn();
  const ahUnload = getUnloadFn();
  const ahSupply = getSupplyFn();
  const ahRepair = getRepairFn();
  const ahHide = getHideFn();
  const ahUnhide = getUnhideFn();
  const ahJoin = getJoinFn();
  const ahLaunch = getLaunchFn();
  const ahNextTurn = getNextTurnFn();
  const ahElimination = getEliminationFn();
  const ahPower = getPowerFn();
  const ahGameOver = getGameOverFn();
  const ahResign = getResignFn();
  /**
   * Intercept functions and add our own handlers to the website.
   */
  function addHandlers() {
    if (isMaintenance()) return;
    // Global handlers
    addUpdateCursorObserver(onCursorMove);
    // Specific page handlers
    if (isMapEditor()) {
      return;
    }
    if (isMovePlanner()) {
      return;
    }
    if (isGamePageAndActive()) {
      addReplayHandlers();
      addGameHandlers();
      return;
    }
  }
  /**
   * Syncs the music with the game state. Does not randomize the COs.
   */
  function syncMusic() {
    musicSettings.themeType = getCurrentThemeType();
    playThemeSong();
    window.setTimeout(() => {
      playThemeSong();
    }, 500);
  }
  /**
   * Refreshes everything needed for the music when finishing a turn. Also randomizes the COs if needed.
   * @param playDelayMS - The delay in milliseconds before the theme song starts playing.
   */
  function refreshMusicForNextTurn(playDelayMS = 0) {
    // It's a new turn, so we need to clear the visibility map, randomize COs, and play the theme song
    visibilityMap.clear();
    musicSettings.randomizeCO();
    musicSettings.themeType = getCurrentThemeType();
    window.setTimeout(() => {
      musicSettings.themeType = getCurrentThemeType();
      playThemeSong();
      window.setTimeout(playThemeSong, 250);
    }, playDelayMS);
  }
  /**
   * Add all handlers that will intercept clicks and functions when watching a replay.
   */
  function addReplayHandlers() {
    const replayForwardActionBtn = getReplayForwardActionBtn();
    const replayBackwardActionBtn = getReplayBackwardActionBtn();
    const replayForwardBtn = getReplayForwardBtn();
    const replayBackwardBtn = getReplayBackwardBtn();
    // const replayOpenBtn = getReplayOpenBtn();
    const replayCloseBtn = getReplayCloseBtn();
    const replayDaySelectorCheckBox = getReplayDaySelectorCheckBox();
    // Keep the music in sync, we do not need to handle turn changes because onQueryTurn will handle that
    replayBackwardActionBtn.addEventListener("click", syncMusic);
    replayForwardActionBtn.addEventListener("click", syncMusic);
    replayForwardBtn.addEventListener("click", syncMusic);
    replayBackwardBtn.addEventListener("click", syncMusic);
    replayDaySelectorCheckBox.addEventListener("change", syncMusic);
    replayCloseBtn.addEventListener("click", syncMusic);
    // Stop all movement sounds when we go backwards on action
    replayBackwardActionBtn.addEventListener("click", stopAllMovementSounds);
    // onQueryTurn isn't called when closing the replay viewer, so change the music for the turn change here
    replayCloseBtn.addEventListener("click", () => refreshMusicForNextTurn(500));
  }
  /**
   * Add all handlers that will intercept clicks and functions during a game.
   */
  function addGameHandlers() {
    // updateCursor = onCursorMove;
    queryTurn = onQueryTurn;
    showEventScreen = onShowEventScreen;
    showEndGameScreen = onShowEndGameScreen;
    openMenu = onOpenMenu;
    closeMenu = onCloseMenu;
    createDamageSquares = onCreateDamageSquares;
    unitClickHandler = onUnitClick;
    waitUnit = onUnitWait;
    animUnit = onAnimUnit;
    animExplosion = onAnimExplosion;
    updateAirUnitFogOnMove = onFogUpdate;
    actionHandlers.Fire = onFire;
    actionHandlers.AttackSeam = onAttackSeam;
    actionHandlers.Move = onMove;
    actionHandlers.Capt = onCapture;
    actionHandlers.Build = onBuild;
    actionHandlers.Load = onLoad;
    actionHandlers.Unload = onUnload;
    actionHandlers.Supply = onSupply;
    actionHandlers.Repair = onRepair;
    actionHandlers.Hide = onHide;
    actionHandlers.Unhide = onUnhide;
    actionHandlers.Join = onJoin;
    actionHandlers.Launch = onLaunch;
    actionHandlers.NextTurn = onNextTurn;
    actionHandlers.Elimination = onElimination;
    actionHandlers.Power = onPower;
    actionHandlers.GameOver = onGameOver;
    actionHandlers.Resign = onResign;
    addConnectionErrorObserver(onConnectionError);
  }
  function onCursorMove(cursorX, cursorY) {
    // ahCursorMove?.apply(ahCursorMove, [cursorX, cursorY]);
    if (!musicSettings.isPlaying) return;
    // debug("Cursor Move", cursorX, cursorY);
    const dx = Math.abs(cursorX - lastCursorX);
    const dy = Math.abs(cursorY - lastCursorY);
    const cursorMoved = dx >= 1 || dy >= 1;
    const timeSinceLastCursorCall = Date.now() - lastCursorCall;
    // Don't play the sound if we moved the cursor too quickly
    if (timeSinceLastCursorCall < CURSOR_THRESHOLD_MS) return;
    if (cursorMoved) {
      playSFX(GameSFX.uiCursorMove);
      lastCursorCall = Date.now();
    }
    lastCursorX = cursorX;
    lastCursorY = cursorY;
  }
  function onQueryTurn(gameId, turn, turnPId, turnDay, replay, initial) {
    const result = ahQueryTurn?.apply(ahQueryTurn, [gameId, turn, turnPId, turnDay, replay, initial]);
    if (!musicSettings.isPlaying) return result;
    // log("Query Turn", gameId, turn, turnPId, turnDay, replay, initial);
    refreshMusicForNextTurn(250);
    return result;
  }
  function onShowEventScreen(event) {
    ahShowEventScreen?.apply(ahShowEventScreen, [event]);
    if (!musicSettings.isPlaying) return;
    // debug("Show Event Screen", event);
    if (hasGameEnded()) {
      refreshMusicForNextTurn();
      return;
    }
    playThemeSong();
    window.setTimeout(playThemeSong, 500);
  }
  function onShowEndGameScreen(event) {
    ahShowEndGameScreen?.apply(ahShowEndGameScreen, [event]);
    if (!musicSettings.isPlaying) return;
    // debug("Show End Game Screen", event);
    refreshMusicForNextTurn();
  }
  function onOpenMenu(menu, x, y) {
    ahOpenMenu?.apply(openMenu, [menu, x, y]);
    if (!musicSettings.isPlaying) return;
    // debug("Open Menu", menu, x, y);
    currentMenuType = MenuOpenType.Regular;
    playSFX(GameSFX.uiMenuOpen);
    const menuOptions = document.getElementsByClassName("menu-option");
    for (let i = 0; i < menuOptions.length; i++) {
      menuOptions[i].addEventListener("mouseenter", (_e) => playSFX(GameSFX.uiMenuMove));
      menuOptions[i].addEventListener("click", (event) => {
        const target = event.target;
        if (!target) return;
        // Check if we clicked on a unit we cannot buy
        if (
          target.classList.contains("forbidden") ||
          target.parentElement?.classList.contains("forbidden") ||
          target.parentElement?.parentElement?.classList.contains("forbidden") ||
          target.parentElement?.parentElement?.parentElement?.classList.contains("forbidden")
        ) {
          playSFX(GameSFX.uiInvalid);
          return;
        }
        currentMenuType = MenuOpenType.None;
        playSFX(GameSFX.uiMenuOpen);
      });
    }
  }
  function onCloseMenu() {
    ahCloseMenu?.apply(closeMenu, []);
    if (!musicSettings.isPlaying) return;
    const isMenuOpen = currentMenuType !== MenuOpenType.None;
    // debug("CloseMenu", currentMenuType, isMenuOpen);
    if (isMenuOpen) {
      playSFX(GameSFX.uiMenuClose);
      clickedDamageSquaresMap.clear();
      currentMenuType = MenuOpenType.None;
    }
  }
  function onCreateDamageSquares(attackerUnit, unitsInRange, movementInfo, movingUnit) {
    ahCreateDamageSquares?.apply(createDamageSquares, [attackerUnit, unitsInRange, movementInfo, movingUnit]);
    if (!musicSettings.isPlaying) return;
    // debug("Create Damage Squares", attackerUnit, unitsInRange, movementInfo, movingUnit);
    // Hook up to all new damage squares
    for (const damageSquare of getAllDamageSquares()) {
      damageSquare.addEventListener("click", (event) => {
        if (!event.target) return;
        const targetSpan = event.target;
        playSFX(GameSFX.uiMenuOpen);
        // If we have clicked this before, then this click is to finalize the attack so no more open menu
        if (clickedDamageSquaresMap.has(targetSpan)) {
          currentMenuType = MenuOpenType.None;
          clickedDamageSquaresMap.clear();
          return;
        }
        // If we haven't clicked this before, then consider it like opening a menu
        currentMenuType = MenuOpenType.DamageSquare;
        clickedDamageSquaresMap.set(targetSpan, true);
      });
    }
  }
  function onUnitClick(clicked) {
    ahUnitClick?.apply(unitClickHandler, [clicked]);
    if (!musicSettings.isPlaying) return;
    // debug("Unit Click", clicked);
    // Check if we clicked on a waited unit or an enemy unit, if so, no more actions can be taken
    const unitInfo = getUnitInfo(Number(clicked.id));
    if (!unitInfo) return;
    const myID = getMyID();
    const isUnitWaited = hasUnitMovedThisTurn(unitInfo.units_id);
    const isMyUnit = unitInfo?.units_players_id === myID;
    const isMyTurn = currentTurn === myID;
    const canActionsBeTaken = !isUnitWaited && isMyUnit && isMyTurn && !isReplayActive();
    // If action can be taken, then we can cancel out of that action
    currentMenuType = canActionsBeTaken ? MenuOpenType.UnitSelect : MenuOpenType.None;
    playSFX(GameSFX.uiUnitSelect);
  }
  function onUnitWait(unitId) {
    ahWait?.apply(waitUnit, [unitId]);
    if (!musicSettings.isPlaying) return;
    // debug("Wait", unitId, getUnitName(unitId));
    // Check if we stopped because we got trapped
    if (movementResponseMap.has(unitId)) {
      const response = movementResponseMap.get(unitId);
      if (response?.trapped) {
        playSFX(GameSFX.unitTrap);
      }
      stopMovementSound(unitId, !response?.trapped);
      movementResponseMap.delete(unitId);
      return;
    }
    stopMovementSound(unitId);
  }
  function onAnimUnit(path, unitId, unitSpan, unitTeam, viewerTeam, i) {
    ahAnimUnit?.apply(animUnit, [path, unitId, unitSpan, unitTeam, viewerTeam, i]);
    if (!musicSettings.isPlaying) return;
    // debug("AnimUnit", path, unitId, unitSpan, unitTeam, viewerTeam, i);
    // Only check if valid
    if (!isValidUnit(unitId) || !path || !i) return;
    // Don't go outside the bounds of the path
    if (i >= path.length) return;
    // The unit disappeared already, no need to stop its sound again
    if (visibilityMap.has(unitId)) return;
    // A visible unit just disappeared
    const unitVisible = path[i].unit_visible;
    if (!unitVisible) {
      visibilityMap.set(unitId, unitVisible);
      // Stop the sound after a little delay, giving more time to react to it
      window.setTimeout(() => stopMovementSound(unitId, false), 1000);
    }
  }
  function onAnimExplosion(unit) {
    ahAnimExplosion?.apply(animExplosion, [unit]);
    if (!musicSettings.isPlaying) return;
    // debug("Exploded", unit);
    const unitId = unit.units_id;
    const unitFuel = unit.units_fuel;
    let sfx = GameSFX.unitExplode;
    if (getUnitName(unitId) === "Black Bomb" && unitFuel > 0) {
      sfx = GameSFX.unitMissileHit;
    }
    playSFX(sfx);
    stopMovementSound(unitId, false);
  }
  function onFogUpdate(x, y, mType, neighbours, unitVisible, change, delay) {
    ahFog?.apply(updateAirUnitFogOnMove, [x, y, mType, neighbours, unitVisible, change, delay]);
    if (!musicSettings.isPlaying) return;
    // debug("Fog", x, y, mType, neighbours, unitVisible, change, delay);
    const unitInfo = getUnitInfoFromCoords(x, y);
    if (!unitInfo) return;
    if (change === "Add") {
      window.setTimeout(() => stopMovementSound(unitInfo.units_id, true), delay);
    }
  }
  function onFire(response) {
    if (!musicSettings.isPlaying) {
      ahFire?.apply(actionHandlers.Fire, [response]);
      return;
    }
    // debug("Fire", response);
    const attackerID = response.copValues.attacker.playerId;
    const defenderID = response.copValues.defender.playerId;
    // stopMovementSound(response.attacker.units_id, false);
    // stopMovementSound(response.defender.units_id, false);
    // Let the user hear a confirmation sound
    // if (currentPlayer.info.players_id == attackerID) {
    //   playSFX(gameSFX.uiMenuOpen);
    // }
    // Calculate charge before attack
    const couldAttackerActivateSCOPBefore = canPlayerActivateSuperCOPower(attackerID);
    const couldAttackerActivateCOPBefore = canPlayerActivateCOPower(attackerID);
    const couldDefenderActivateSCOPBefore = canPlayerActivateSuperCOPower(defenderID);
    const couldDefenderActivateCOPBefore = canPlayerActivateCOPower(defenderID);
    // Let the attack proceed normally
    ahFire?.apply(actionHandlers.Fire, [response]);
    // Check if the attack gave enough charge for a power to either side
    // Give it a little bit of time for the animation if needed
    const delay = areAnimationsEnabled() ? 750 : 0;
    const canAttackerActivateSCOPAfter = canPlayerActivateSuperCOPower(attackerID);
    const canAttackerActivateCOPAfter = canPlayerActivateCOPower(attackerID);
    const canDefenderActivateSCOPAfter = canPlayerActivateSuperCOPower(defenderID);
    const canDefenderActivateCOPAfter = canPlayerActivateCOPower(defenderID);
    const madeSCOPAvailable =
      (!couldAttackerActivateSCOPBefore && canAttackerActivateSCOPAfter) ||
      (!couldDefenderActivateSCOPBefore && canDefenderActivateSCOPAfter);
    const madeCOPAvailable =
      (!couldAttackerActivateCOPBefore && canAttackerActivateCOPAfter) ||
      (!couldDefenderActivateCOPBefore && canDefenderActivateCOPAfter);
    window.setTimeout(() => {
      if (madeSCOPAvailable) playSFX(GameSFX.powerSCOPAvailable);
      else if (madeCOPAvailable) playSFX(GameSFX.powerCOPAvailable);
    }, delay);
  }
  /**
   * Moves a div back and forth to create a wiggle effect.
   * @param div - The div to wiggle.
   * @param startDelay - The delay in milliseconds before the wiggle starts.
   */
  function wiggleTile(div, startDelay = 0) {
    const stepsX = 12;
    const stepsY = 4;
    const deltaX = 0.2;
    const deltaY = 0.05;
    const wiggleAnimation = () => {
      moveDivToOffset(
        div,
        deltaX,
        0,
        stepsX,
        { then: [0, -deltaY, stepsY] },
        { then: [-deltaX * 2, 0, stepsX] },
        { then: [deltaX * 2, 0, stepsX] },
        { then: [0, -deltaY, stepsY] },
        { then: [-deltaX * 2, 0, stepsX] },
        { then: [deltaX * 2, 0, stepsX] },
        { then: [0, deltaY, stepsY] },
        { then: [-deltaX * 2, 0, stepsX] },
        { then: [deltaX, 0, stepsX] },
        { then: [0, deltaY, stepsY] },
      );
    };
    window.setTimeout(wiggleAnimation, startDelay);
  }
  function onAttackSeam(response) {
    ahAttackSeam?.apply(actionHandlers.AttackSeam, [response]);
    if (!musicSettings.isPlaying) return;
    // debug("AttackSeam", response);
    const seamWasDestroyed = response.seamHp <= 0;
    // Pipe wiggle animation
    if (areAnimationsEnabled()) {
      const x = response.seamX;
      const y = response.seamY;
      const pipeSeamInfo = getBuildingInfo(x, y);
      if (!pipeSeamInfo) return;
      const pipeSeamDiv = getBuildingDiv(pipeSeamInfo.buildings_id);
      // Subtract how long the wiggle takes so it matches the sound a bit better
      const wiggleDelay = seamWasDestroyed ? 0 : attackDelayMS;
      wiggleTile(pipeSeamDiv, wiggleDelay);
    }
    if (seamWasDestroyed) {
      playSFX(GameSFX.unitAttackPipeSeam);
      playSFX(GameSFX.unitExplode);
      return;
    }
    window.setTimeout(() => playSFX(GameSFX.unitAttackPipeSeam), attackDelayMS);
  }
  function onMove(response, loadFlag) {
    ahMove?.apply(actionHandlers.Move, [response, loadFlag]);
    if (!musicSettings.isPlaying) return;
    // debug("Move", response, loadFlag);
    const unitId = response.unit.units_id;
    movementResponseMap.set(unitId, response);
    const movementDist = response.path.length;
    stopMovementSound(unitId, false);
    if (movementDist > 1) {
      playMovementSound(unitId);
    }
  }
  function onCapture(data) {
    ahCapt?.apply(actionHandlers.Capt, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Capt", data);
    // They didn't finish the capture
    const finishedCapture = data.newIncome != null;
    if (!finishedCapture) {
      playSFX(GameSFX.unitCaptureProgress);
      return;
    }
    // The unit is done capping this property
    const myID = getMyID();
    const isSpectator = isPlayerSpectator(myID);
    // Don't use triple equals blindly here because the types are different
    // buildings_team (string) == id (number)
    const isMyCapture = data.buildingInfo.buildings_team === myID.toString() || isSpectator;
    const sfx = isMyCapture ? GameSFX.unitCaptureAlly : GameSFX.unitCaptureEnemy;
    playSFX(sfx);
  }
  function onBuild(data) {
    ahBuild?.apply(actionHandlers.Build, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Build", data);
    const myID = getMyID();
    const isMyBuild = data.newUnit.units_players_id == myID;
    const isReplay = isReplayActive();
    if (!isMyBuild || isReplay) playSFX(GameSFX.unitSupply);
  }
  function onLoad(data) {
    ahLoad?.apply(actionHandlers.Load, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Load", data);
    playSFX(GameSFX.unitLoad);
  }
  function onUnload(data) {
    ahUnload?.apply(actionHandlers.Unload, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Unload", data);
    playSFX(GameSFX.unitUnload);
  }
  function onSupply(data) {
    ahSupply?.apply(actionHandlers.Supply, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Supply", data);
    // We could play the sfx for each supplied unit in the list
    // but instead we decided to play the supply sound once.
    playSFX(GameSFX.unitSupply);
  }
  function onRepair(data) {
    ahRepair?.apply(actionHandlers.Repair, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Repair", data);
    playSFX(GameSFX.unitSupply);
  }
  function onHide(data) {
    ahHide?.apply(actionHandlers.Hide, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Hide", data);
    playSFX(GameSFX.unitHide);
    stopMovementSound(data.unitId);
  }
  function onUnhide(data) {
    ahUnhide?.apply(actionHandlers.Unhide, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Unhide", data);
    playSFX(GameSFX.unitUnhide);
    stopMovementSound(data.unitId);
  }
  function onJoin(data) {
    ahJoin?.apply(actionHandlers.Join, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Join", data);
    stopMovementSound(data.joinID);
    stopMovementSound(data.joinedUnit.units_id);
  }
  function onLaunch(data) {
    ahLaunch?.apply(actionHandlers.Launch, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Launch", data);
    playSFX(GameSFX.unitMissileSend);
    window.setTimeout(() => playSFX(GameSFX.unitMissileHit), siloDelayMS);
  }
  function onNextTurn(data) {
    ahNextTurn?.apply(actionHandlers.NextTurn, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("NextTurn", data);
    if (data.swapCos) {
      playSFX(GameSFX.tagSwap);
    }
    refreshMusicForNextTurn();
  }
  function onElimination(data) {
    ahElimination?.apply(actionHandlers.Elimination, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Elimination", data);
    // Play the elimination sound
    refreshMusicForNextTurn();
  }
  function onGameOver() {
    ahGameOver?.apply(actionHandlers.GameOver, []);
    if (!musicSettings.isPlaying) return;
    // debug("GameOver");
    refreshMusicForNextTurn();
  }
  function onResign(data) {
    ahResign?.apply(actionHandlers.Resign, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Resign", data);
    refreshMusicForNextTurn();
  }
  function onPower(data) {
    ahPower?.apply(actionHandlers.Power, [data]);
    if (!musicSettings.isPlaying) return;
    // debug("Power", data);
    // Remember, these are in title case with spaces like "Colin" or "Von Bolt"
    const coName = data.coName;
    const isBH = isBlackHoleCO(coName);
    const isSuperCOPower = data.coPower === COPowerEnum.SuperCOPower;
    // Update the theme type
    musicSettings.themeType = isSuperCOPower ? ThemeType.SUPER_CO_POWER : ThemeType.CO_POWER;
    switch (musicSettings.gameType) {
      case GameType.AW1:
        // Advance Wars 1 will use the same sound for both CO and Super CO power activations
        playSFX(GameSFX.powerActivateAW1COP);
        stopThemeSong(4500);
        return;
      case GameType.AW2:
      case GameType.DS:
      case GameType.RBC: {
        // Super CO Power
        if (isSuperCOPower) {
          const sfx = isBH ? GameSFX.powerActivateBHSCOP : GameSFX.powerActivateAllySCOP;
          const delay = isBH ? 1916 : 1100;
          playSFX(sfx);
          stopThemeSong(delay);
          break;
        }
        // Regular CO Power
        const sfx = isBH ? GameSFX.powerActivateBHCOP : GameSFX.powerActivateAllyCOP;
        const delay = isBH ? 1019 : 881;
        playSFX(sfx);
        stopThemeSong(delay);
        break;
      }
    }
    // Colin's gold rush SFX for AW2, DS, and RBC
    if (coName === "Colin" && !isSuperCOPower) {
      window.setTimeout(() => playSFX(GameSFX.coGoldRush), 800);
    }
  }
  function onConnectionError(closeMsg) {
    closeMsg = closeMsg.toLowerCase();
    if (closeMsg.includes("connected to another game")) stopThemeSong();
  }

  /**
   * @file Main script that loads everything for the AWBW Improved Music Player userscript.
   *
   * @TODO - More map editor sound effects
   */
  /******************************************************************
   * Functions
   ******************************************************************/
  /**
   * Where should we place the music player UI?
   */
  function getMenu() {
    if (isMaintenance()) return document.querySelector("#main");
    if (isMapEditor()) return document.querySelector("#replay-misc-controls");
    if (isMovePlanner()) return document.querySelector("#map-controls-container");
    if (isYourGames()) return document.querySelector("#nav-options");
    return document.querySelector("#game-map-menu")?.parentNode;
  }
  /**
   * Adjust the music player for the Live Queue page.
   */
  function onLiveQueue() {
    log("Live Queue detected...");
    const addMusicFn = () => {
      // Check if the parent popup is created and visible
      const blockerPopup = getLiveQueueBlockerPopup();
      if (!blockerPopup) return false;
      if (blockerPopup.style.display === "none") return false;
      // Now make sure the internal popup is created
      const popup = getLiveQueueSelectPopup();
      if (!popup) return false;
      // Get the div with "Match starts in ...."
      const box = popup.querySelector(".flex.row.hv-center");
      if (!box) return false;
      // Prepend the music player UI to the box
      musicPlayerUI.addToAWBWPage(box, true);
      musicSettings.isPlaying = musicSettings.autoplayOnOtherPages;
      playMusicURL("https://developerjose.netlify.app/music/t-co-select.ogg" /* SpecialTheme.COSelect */);
      allowSettingsToBeSaved();
      playOrPauseWhenWindowFocusChanges();
      return true;
    };
    const checkStillActiveFn = () => {
      const blockerPopup = getLiveQueueBlockerPopup();
      return blockerPopup?.style.display !== "none";
    };
    const addPlayerIntervalID = window.setInterval(() => {
      if (!addMusicFn()) return;
      // We don't need to add the music player anymore
      clearInterval(addPlayerIntervalID);
      // Now we need to check if we need to pause/resume the music because the player left/rejoined
      // We will do this indefinitely until eventually the player accepts a match or leaves the page
      window.setInterval(() => {
        // We are still in the CO select, play the music
        if (checkStillActiveFn()) playThemeSong();
        // We are not in the CO select, stop the music
        else stopThemeSong();
      }, 500);
    }, 500);
  }
  /**
   * Adjust the music player for the maintenance page.
   */
  function onMaintenance() {
    log("Maintenance detected, playing music...");
    musicPlayerUI.openContextMenu();
    musicSettings.randomThemesType = RandomThemeType.NONE;
    playMusicURL("https://developerjose.netlify.app/music/t-maintenance.ogg" /* SpecialTheme.Maintenance */);
    allowSettingsToBeSaved();
  }
  /**
   * Adjust the music player for the Move Planner page.
   */
  function onMovePlanner() {
    log("Move Planner detected");
    musicSettings.isPlaying = true;
    allowSettingsToBeSaved();
  }
  /**
   * Adjust the music player for the Your Games and Your Turn pages.
   */
  function onIsYourGames() {
    log("Your Games detected, playing music...");
    playMusicURL("https://developerjose.netlify.app/music/t-mode-select.ogg" /* SpecialTheme.ModeSelect */);
    allowSettingsToBeSaved();
    playOrPauseWhenWindowFocusChanges();
  }
  /**
   * Adjust the music player for the map editor page.
   */
  function onMapEditor() {
    playOrPauseWhenWindowFocusChanges();
  }
  /**
   * Whether the music player has been initialized or not.
   */
  let isMusicPlayerInitialized = false;
  /**
   * Initializes the music player script by setting everything up.
   */
  function initializeMusicPlayer() {
    if (isMusicPlayerInitialized) return;
    isMusicPlayerInitialized = true;
    // Load settings from local storage but don't allow saving yet
    loadSettingsFromLocalStorage();
    // Override the saved setting for autoplay if we are on a different page than the main game page
    if (!isGamePageAndActive()) musicSettings.isPlaying = musicSettings.autoplayOnOtherPages;
    // Handle pages that aren't the main game page or the map editor
    addHandlers();
    if (isLiveQueue()) return onLiveQueue();
    if (isMaintenance()) return onMaintenance();
    if (isMovePlanner()) return onMovePlanner();
    if (isYourGames()) return onIsYourGames();
    // game.php or designmap.php from now on
    if (isMapEditor()) onMapEditor();
    allowSettingsToBeSaved();
    preloadAllCommonAudio(() => {
      log("All common audio has been pre-loaded!");
      // Set dynamic settings based on the current game state
      // Lastly, update the UI to reflect the current settings
      musicSettings.themeType = getCurrentThemeType();
      musicPlayerUI.updateAllInputLabels();
      playThemeSong();
      // Check for new music files every minute
      const checkHashesMS = 1000 * 60 * 1;
      const checkHashesFn = () => {
        checkHashesInDB()
          .then(() => log("All music files have been checked for updates."))
          .catch((reason) => logError("Could not check for music file updates:", reason));
        window.setTimeout(checkHashesFn, checkHashesMS);
      };
      checkHashesFn();
      // preloadAllAudio(() => {
      //   log("All other audio has been pre-loaded!");
      // });
    });
  }
  /**
   * Initializes and adds the music player UI to the page.
   */
  function initializeUI() {
    // Add the music player UI to the page and the necessary event handlers
    if (!isLiveQueue()) musicPlayerUI.addToAWBWPage(getMenu(), isYourGames());
    musicPlayerUI.setProgress(100);
    // Make adjustments to the UI based on the page we are on
    if (isYourGames()) {
      musicPlayerUI.parent.style.border = "none";
      musicPlayerUI.parent.style.backgroundColor = "#0000";
      musicPlayerUI.setProgress(-1);
    }
    if (isMapEditor()) {
      musicPlayerUI.parent.style.borderTop = "none";
    }
    if (isMaintenance()) {
      musicPlayerUI.parent.style.borderLeft = "";
    }
  }
  /**
   * Main function that initializes everything depending on the browser autoplay settings.
   */
  function main() {
    initializeUI();
    const ifCanAutoplay = () => {
      initializeMusicPlayer();
    };
    const ifCannotAutoplay = () => {
      // Listen for any clicks
      musicPlayerUI.addEventListener("click", () => initializeMusicPlayer(), { once: true });
      document.querySelector("body")?.addEventListener("click", () => initializeMusicPlayer(), { once: true });
    };
    // Check if we can autoplay
    canAutoplay
      .audio()
      .then((response) => {
        const result = response.result;
        logDebug("Script starting, does your browser allow you to auto-play:", result);
        if (result) ifCanAutoplay();
        else ifCannotAutoplay();
      })
      .catch((reason) => {
        logDebug("Script starting, could not check your browser allows auto-play so assuming no: ", reason);
        ifCannotAutoplay();
      });
  }
  /******************************************************************
   * SCRIPT ENTRY (MAIN FUNCTION)
   ******************************************************************/
  // Open the database for caching music files first
  // No matter what happens, we will initialize the music player
  log("Opening database to cache music files.");
  openDB()
    .then(() => log("Database opened successfully. Ready to cache music files."))
    .catch((reason) => logDebug(`Database Error: ${reason}. Will not be able to cache music files locally.`))
    .finally(main);

  exports.initializeMusicPlayer = initializeMusicPlayer;
  exports.initializeUI = initializeUI;
  exports.main = main;
  exports.notifyCOSelectorListeners = notifyCOSelectorListeners;

  return exports;
})({}, canAutoplay, Howl, SparkMD5);