QOLbox

Persist hitbox.io game and jukebox volume, with exponential audio taper, wheel controls, and jukebox mute.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         QOLbox
// @namespace    Violentmonkey Scripts
// @author       gpt-5.4-high (Codex CLI), steered by Aggressive Combo
// @version      1.0.0
// @description  Persist hitbox.io game and jukebox volume, with exponential audio taper, wheel controls, and jukebox mute.
// @license      ISC
// @match        https://hitbox.io/game2.html*
// @match        https://www.hitbox.io/game2.html*
// @run-at       document-start
// @inject-into  page
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const GAME_VOLUME_KEY = 'vm.hitbox.volumePercent';
  const JUKEBOX_STATE_KEY = 'vm.hitbox.jukeboxState';

  const STEP_PERCENT = 5;
  const DEFAULT_GAME_PERCENT = 100;
  const DEFAULT_JUKEBOX_PERCENT = 50;
  const GAME_CURVE_EXPONENT = 2;
  const JUKEBOX_CURVE_EXPONENT = 2;

  const JUKEBOX_MIN_ANGLE = -40;
  const JUKEBOX_MAX_ANGLE = 220;
  const JUKEBOX_ARC_CENTER = 14;
  const JUKEBOX_ARC_RADIUS = 12;
  const JUKEBOX_WHEEL_STEP = 5;
  const JUKEBOX_DRAG_SENSITIVITY = 1;
  const TICK_MS = 400;

  let gamePercent = loadGamePercent();
  let currentGameMenuItem = null;
  let currentJukeboxMenuItem = null;
  let originalHowlVolume = null;
  let settingGameVolumeInternally = false;

  let jukeboxState = loadJukeboxState();
  let activeKnobDrag = null;
  let trackedPlayers = new Set();
  let youTubeHookInstalled = false;

  function clampPercent(value, fallback = 0) {
    const numericValue = Number(value);
    if (!Number.isFinite(numericValue)) {
      return fallback;
    }

    const steppedValue = Math.round(numericValue / STEP_PERCENT) * STEP_PERCENT;
    return Math.min(100, Math.max(0, steppedValue));
  }

  function clampJukeboxPercent(value) {
    const numericValue = Number(value);
    if (!Number.isFinite(numericValue)) {
      return DEFAULT_JUKEBOX_PERCENT;
    }

    return Math.min(100, Math.max(0, Math.round(numericValue)));
  }

  function loadGamePercent() {
    try {
      return clampPercent(localStorage.getItem(GAME_VOLUME_KEY), DEFAULT_GAME_PERCENT);
    } catch {
      return DEFAULT_GAME_PERCENT;
    }
  }

  function saveGamePercent() {
    try {
      localStorage.setItem(GAME_VOLUME_KEY, String(gamePercent));
    } catch {
      // Ignore storage failures.
    }
  }

  function loadJukeboxState() {
    const fallback = {
      percent: null,
      muted: false,
    };

    try {
      const rawState = localStorage.getItem(JUKEBOX_STATE_KEY);
      if (!rawState) {
        return fallback;
      }

      const parsed = JSON.parse(rawState);
      return {
        percent:
          parsed && parsed.percent !== null && parsed.percent !== undefined
            ? clampJukeboxPercent(parsed.percent)
            : null,
        muted: Boolean(parsed && parsed.muted),
      };
    } catch {
      return fallback;
    }
  }

  function saveJukeboxState() {
    try {
      localStorage.setItem(JUKEBOX_STATE_KEY, JSON.stringify(jukeboxState));
    } catch {
      // Ignore storage failures.
    }
  }

  function percentToGameScalar(percent) {
    return Math.pow(clampPercent(percent, DEFAULT_GAME_PERCENT) / 100, GAME_CURVE_EXPONENT);
  }

  function percentToJukeboxVolume(percent) {
    const normalized = clampJukeboxPercent(percent) / 100;
    return Math.round(Math.pow(normalized, JUKEBOX_CURVE_EXPONENT) * 100);
  }

  function percentToJukeboxAngle(percent) {
    const normalized = clampJukeboxPercent(percent) / 100;
    return JUKEBOX_MIN_ANGLE + (JUKEBOX_MAX_ANGLE - JUKEBOX_MIN_ANGLE) * normalized;
  }

  function angleToJukeboxPercent(angle) {
    const numericAngle = Number(angle);
    if (!Number.isFinite(numericAngle)) {
      return DEFAULT_JUKEBOX_PERCENT;
    }

    const normalized =
      (Math.min(JUKEBOX_MAX_ANGLE, Math.max(JUKEBOX_MIN_ANGLE, numericAngle)) - JUKEBOX_MIN_ANGLE) /
      (JUKEBOX_MAX_ANGLE - JUKEBOX_MIN_ANGLE);
    return clampJukeboxPercent(normalized * 100);
  }

  function polarToArcPoint(angle) {
    const radians = ((angle + 180) * Math.PI) / 180;
    return {
      x: JUKEBOX_ARC_CENTER + JUKEBOX_ARC_RADIUS * Math.cos(radians),
      y: JUKEBOX_ARC_CENTER + JUKEBOX_ARC_RADIUS * Math.sin(radians),
    };
  }

  function findGameVolumeItem() {
    const candidates = document.querySelectorAll('.items.left .item, .item');
    for (const candidate of candidates) {
      if (/^Volume:\s*\d+%$/.test(candidate.textContent.trim())) {
        return candidate;
      }
    }

    return null;
  }

  function updateGameVolumeText() {
    if (!currentGameMenuItem || !currentGameMenuItem.isConnected) {
      currentGameMenuItem = findGameVolumeItem();
    }

    if (!currentGameMenuItem) {
      return;
    }

    currentGameMenuItem.textContent = `Volume: ${gamePercent}%`;
    currentGameMenuItem.title = 'Scroll to adjust by 5%, left-click up, right-click down';
    currentGameMenuItem.style.cursor = 'ns-resize';
    currentGameMenuItem.style.userSelect = 'none';
  }

  function applyGameVolume() {
    updateGameVolumeText();

    if (!window.Howler || !Array.isArray(window.Howler._howls) || !originalHowlVolume) {
      return;
    }

    settingGameVolumeInternally = true;
    try {
      for (const howl of window.Howler._howls) {
        if (!howl || typeof howl !== 'object') {
          continue;
        }

        if (typeof howl.__vmBaseVolume !== 'number') {
          const initialVolume = Number(howl._volume);
          howl.__vmBaseVolume = Number.isFinite(initialVolume) ? initialVolume : 1;
        }

        originalHowlVolume.call(howl, howl.__vmBaseVolume * percentToGameScalar(gamePercent));
      }
    } finally {
      settingGameVolumeInternally = false;
    }
  }

  function setGamePercent(nextPercent) {
    gamePercent = clampPercent(nextPercent, DEFAULT_GAME_PERCENT);
    saveGamePercent();
    applyGameVolume();
  }

  function patchGameVolumeMenu() {
    const item = findGameVolumeItem();
    if (!item) {
      return false;
    }

    currentGameMenuItem = item;

    if (!item.dataset.vmHitboxGameVolumePatched) {
      item.dataset.vmHitboxGameVolumePatched = 'true';
      item.onclick = event => {
        event.preventDefault();
        event.stopPropagation();
        setGamePercent(gamePercent + STEP_PERCENT);
      };
      item.oncontextmenu = event => {
        event.preventDefault();
        event.stopPropagation();
        setGamePercent(gamePercent - STEP_PERCENT);
      };
      item.addEventListener(
        'wheel',
        event => {
          event.preventDefault();
          event.stopPropagation();
          setGamePercent(gamePercent + (event.deltaY < 0 ? STEP_PERCENT : -STEP_PERCENT));
        },
        { passive: false }
      );
    }

    updateGameVolumeText();
    return true;
  }

  function hookHowlPrototype() {
    const HowlCtor = window.Howl;
    if (!HowlCtor || !HowlCtor.prototype || typeof HowlCtor.prototype.volume !== 'function') {
      return false;
    }

    if (HowlCtor.prototype.volume.__vmHitboxWrapped) {
      return true;
    }

    originalHowlVolume = HowlCtor.prototype.volume;

    function wrappedVolume(value, ...rest) {
      if (arguments.length === 0) {
        if (typeof this.__vmBaseVolume === 'number') {
          return this.__vmBaseVolume;
        }
        return originalHowlVolume.call(this);
      }

      if (typeof value === 'number' && !settingGameVolumeInternally) {
        this.__vmBaseVolume = value;
        return originalHowlVolume.call(this, value * percentToGameScalar(gamePercent), ...rest);
      }

      return originalHowlVolume.call(this, value, ...rest);
    }

    wrappedVolume.__vmHitboxWrapped = true;
    HowlCtor.prototype.volume = wrappedVolume;
    applyGameVolume();
    return true;
  }

  function findSettingsContainer() {
    return document.querySelector('.items.left');
  }

  function findChangeControlsItem(container) {
    if (!container) {
      return null;
    }

    const items = container.querySelectorAll('.item');
    for (const item of items) {
      if (item.textContent.trim() === 'Change Controls') {
        return item;
      }
    }

    return null;
  }

  function getJukeboxMenuLabel() {
    return jukeboxState.muted ? 'Unmute Jukebox' : 'Mute Jukebox';
  }

  function updateJukeboxMenuItem() {
    if (!currentJukeboxMenuItem || !currentJukeboxMenuItem.isConnected) {
      return;
    }

    currentJukeboxMenuItem.textContent = getJukeboxMenuLabel();
    currentJukeboxMenuItem.title = 'Remember the lobby radio mute state';
  }

  function patchJukeboxMenu() {
    const container = findSettingsContainer();
    if (!container) {
      return false;
    }

    let item = container.querySelector('.item[data-vm-hitbox-jukebox-menu="true"]');
    if (!item) {
      item = document.createElement('div');
      item.className = 'item';
      item.dataset.vmHitboxJukeboxMenu = 'true';
      item.onclick = event => {
        event.preventDefault();
        event.stopPropagation();
        toggleJukeboxMute();
      };

      const beforeItem = findChangeControlsItem(container);
      if (beforeItem) {
        container.insertBefore(item, beforeItem);
      } else {
        container.appendChild(item);
      }
    }

    currentJukeboxMenuItem = item;
    updateJukeboxMenuItem();
    return true;
  }

  function findJukeboxKnob() {
    return document.querySelector('.jukebox .knob.volumeContainer');
  }

  function ensureJukeboxPercent(knob) {
    if (jukeboxState.percent !== null) {
      return;
    }

    const bar = knob ? knob.querySelector('.barSVG') : null;
    const transform = bar ? bar.style.transform || window.getComputedStyle(bar).transform : '';
    const match = typeof transform === 'string' ? transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/i) : null;
    jukeboxState.percent = angleToJukeboxPercent(match ? Number(match[1]) : DEFAULT_JUKEBOX_PERCENT);
    saveJukeboxState();
  }

  function setKnobVisual(knob, percent) {
    if (!knob) {
      return;
    }

    const angle = percentToJukeboxAngle(percent);
    const bar = knob.querySelector('.barSVG');
    const arcPath = knob.querySelector('.arcSVG path');

    if (bar) {
      bar.style.transform = `rotate(${angle}deg)`;
    }

    if (arcPath) {
      const startPoint = polarToArcPoint(JUKEBOX_MIN_ANGLE);
      const endPoint = polarToArcPoint(angle);
      const sweepDegrees = Math.max(0, angle - JUKEBOX_MIN_ANGLE);
      const largeArcFlag = sweepDegrees > 180 ? 1 : 0;
      arcPath.setAttribute(
        'd',
        `M ${startPoint.x} ${startPoint.y} A ${JUKEBOX_ARC_RADIUS} ${JUKEBOX_ARC_RADIUS} 0 ${largeArcFlag} 1 ${endPoint.x} ${endPoint.y}`
      );
    }
  }

  function applyJukeboxStateToKnob(knob) {
    if (!knob || activeKnobDrag) {
      return;
    }

    ensureJukeboxPercent(knob);
    setKnobVisual(knob, jukeboxState.muted ? 0 : jukeboxState.percent);
  }

  function trackPlayer(player) {
    if (!player || typeof player.setVolume !== 'function') {
      return;
    }

    trackedPlayers.add(player);
  }

  function discoverPlayers() {
    const yt = window.YT;
    if (!yt || typeof yt.get !== 'function') {
      return;
    }

    const candidates = document.querySelectorAll('#ytContainer [id], #ytContainer iframe[id]');
    for (const candidate of candidates) {
      if (!candidate.id) {
        continue;
      }

      try {
        const player = yt.get(candidate.id);
        if (player && typeof player.setVolume === 'function') {
          trackPlayer(player);
        }
      } catch {
        // Ignore unresolved ids.
      }
    }
  }

  function applyJukeboxStateToPlayer(player) {
    if (!player || typeof player.setVolume !== 'function') {
      trackedPlayers.delete(player);
      return;
    }

    ensureJukeboxPercent(findJukeboxKnob());

    try {
      if (jukeboxState.muted) {
        if (typeof player.unMute === 'function') {
          player.unMute();
        }
        player.setVolume(0);
        if (typeof player.mute === 'function') {
          player.mute();
        }
      } else {
        if (typeof player.unMute === 'function') {
          player.unMute();
        }
        player.setVolume(percentToJukeboxVolume(jukeboxState.percent));
      }
    } catch {
      trackedPlayers.delete(player);
    }
  }

  function applyJukeboxState() {
    const knob = findJukeboxKnob();
    applyJukeboxStateToKnob(knob);

    discoverPlayers();
    for (const player of Array.from(trackedPlayers)) {
      applyJukeboxStateToPlayer(player);
    }
  }

  function hookYouTubePlayer() {
    const yt = window.YT;
    if (!yt || typeof yt.Player !== 'function') {
      return false;
    }

    if (youTubeHookInstalled || yt.Player.__vmHitboxWrapped) {
      youTubeHookInstalled = true;
      discoverPlayers();
      return true;
    }

    const OriginalPlayer = yt.Player;

    function WrappedPlayer(...args) {
      const instance = new OriginalPlayer(...args);
      trackPlayer(instance);
      window.setTimeout(() => {
        applyJukeboxStateToPlayer(instance);
      }, 0);
      return instance;
    }

    Object.setPrototypeOf(WrappedPlayer, OriginalPlayer);
    WrappedPlayer.prototype = OriginalPlayer.prototype;
    WrappedPlayer.__vmHitboxWrapped = true;
    yt.Player = WrappedPlayer;
    youTubeHookInstalled = true;
    discoverPlayers();
    return true;
  }

  function setJukeboxPercent(nextPercent) {
    jukeboxState.percent = clampJukeboxPercent(nextPercent);
    jukeboxState.muted = false;
    saveJukeboxState();
    updateJukeboxMenuItem();
    setKnobVisual(findJukeboxKnob(), jukeboxState.percent);
    applyJukeboxState();
  }

  function toggleJukeboxMute() {
    ensureJukeboxPercent(findJukeboxKnob());
    jukeboxState.muted = !jukeboxState.muted;
    saveJukeboxState();
    updateJukeboxMenuItem();
    applyJukeboxState();
  }

  function getKnobPercentFromPointer(event) {
    if (!activeKnobDrag) {
      return DEFAULT_JUKEBOX_PERCENT;
    }

    const deltaY = activeKnobDrag.startY - event.clientY;
    const nextPercent = activeKnobDrag.startPercent + deltaY * JUKEBOX_DRAG_SENSITIVITY;
    return clampJukeboxPercent(nextPercent);
  }

  function onKnobPointerMove(event) {
    if (!activeKnobDrag) {
      return;
    }

    event.preventDefault();
    const percent = getKnobPercentFromPointer(event);
    setJukeboxPercent(percent);
  }

  function endKnobDrag() {
    if (!activeKnobDrag) {
      return;
    }

    activeKnobDrag = null;
  }

  function patchGlobalKnobListeners() {
    if (window.__vmHitboxJukeboxGlobalsPatched) {
      return;
    }

    window.__vmHitboxJukeboxGlobalsPatched = true;
    window.addEventListener('pointermove', onKnobPointerMove, true);
    window.addEventListener('mousemove', onKnobPointerMove, true);
    window.addEventListener('pointerup', endKnobDrag, true);
    window.addEventListener('mouseup', endKnobDrag, true);
    window.addEventListener('blur', endKnobDrag, true);
  }

  function patchJukeboxKnob() {
    const knob = findJukeboxKnob();
    if (!knob) {
      return false;
    }

    patchGlobalKnobListeners();
    ensureJukeboxPercent(knob);
    applyJukeboxStateToKnob(knob);

    if (!knob.dataset.vmHitboxJukeboxPatched) {
      knob.dataset.vmHitboxJukeboxPatched = 'true';
      knob.title = 'Scroll or drag to adjust the jukebox volume';
      knob.style.touchAction = 'none';

      const startDrag = event => {
        event.preventDefault();
        event.stopPropagation();

        if (typeof knob.setPointerCapture === 'function' && event.pointerId !== undefined) {
          try {
            knob.setPointerCapture(event.pointerId);
          } catch {
            // Ignore pointer capture failures.
          }
        }

        if (jukeboxState.muted) {
          jukeboxState.muted = false;
          saveJukeboxState();
          updateJukeboxMenuItem();
          applyJukeboxState();
        }

        activeKnobDrag = {
          knob,
          startY: event.clientY,
          startPercent: jukeboxState.muted ? 0 : (jukeboxState.percent ?? DEFAULT_JUKEBOX_PERCENT),
        };
        onKnobPointerMove(event);
      };

      knob.addEventListener('pointerdown', startDrag, true);
      knob.addEventListener(
        'wheel',
        event => {
          event.preventDefault();
          event.stopPropagation();
          ensureJukeboxPercent(knob);

          const currentPercent = jukeboxState.muted ? 0 : jukeboxState.percent;
          setJukeboxPercent(currentPercent + (event.deltaY < 0 ? JUKEBOX_WHEEL_STEP : -JUKEBOX_WHEEL_STEP));
        },
        { passive: false }
      );
    }

    return true;
  }

  function bootstrap() {
    hookHowlPrototype();
    patchGameVolumeMenu();
    hookYouTubePlayer();
    patchJukeboxMenu();
    patchJukeboxKnob();
    applyJukeboxState();
  }

  window.setInterval(bootstrap, TICK_MS);

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
  } else {
    bootstrap();
  }
})();