Equalizer

Press Alt+X to open/close. (Note: Does not work on DRM-protected sites like Netflix/Spotify).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Equalizer
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Press Alt+X to open/close. (Note: Does not work on DRM-protected sites like Netflix/Spotify).
// @author       HyakuAr
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @license MIT
// ==/UserScript==

/* jshint esversion: 11 */

(function () {
  "use strict";

  const BANDS = [
    { freq: 32, label: "32", type: "lowshelf" },
    { freq: 64, label: "64", type: "peaking" },
    { freq: 125, label: "125", type: "peaking" },
    { freq: 250, label: "250", type: "peaking" },
    { freq: 500, label: "500", type: "peaking" },
    { freq: 1000, label: "1K", type: "peaking" },
    { freq: 2000, label: "2K", type: "peaking" },
    { freq: 4000, label: "4K", type: "peaking" },
    { freq: 8000, label: "8K", type: "peaking" },
    { freq: 16000, label: "16K", type: "highshelf" },
  ];
  const PRESETS = {
    Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    "Bass Boost": [10, 8, 4, 1, 0, 0, 0, 0, 0, 0],
    "Treble+": [0, 0, 0, 0, 0, 2, 4, 6, 8, 10],
    "V-Shape": [8, 5, 1, -2, -4, -4, -2, 1, 5, 8],
    Vocal: [-2, -2, 0, 4, 6, 6, 4, 0, -2, -2],
    Rock: [5, 3, 0, -2, 0, 3, 5, 6, 5, 4],
    Classical: [5, 4, 3, 2, 0, 0, 0, -2, -3, -4],
  };

  /* ── STATE ───────────────────────────────────────────── */
  const gains = BANDS.map(() => 0);
  let masterGain = 1;
  let eqEnabled = true;

  /* ── AUDIO INTERCEPTION ──────────────────────────────── */
  const OrigAudioContext = window.AudioContext || window.webkitAudioContext;
  if (!OrigAudioContext) return;

  const allCtx = [];
  const filterMap = new WeakMap();

  function buildChain(ctx) {
    if (filterMap.has(ctx)) return;
    try {
      const filters = BANDS.map((b, i) => {
        const f = ctx.createBiquadFilter();
        f.type = b.type;
        f.frequency.value = b.freq;
        f.Q.value = 1.4;
        f.gain.value = eqEnabled ? gains[i] : 0;
        return f;
      });
      const gainNode = ctx.createGain();
      gainNode.gain.value = eqEnabled ? masterGain : 1;
      for (let i = 0; i < filters.length - 1; i++)
        filters[i].connect(filters[i + 1]);
      filters[filters.length - 1].connect(gainNode);
      gainNode.connect(ctx.destination);
      filterMap.set(ctx, { filters, gainNode });
      allCtx.push(ctx);
    } catch (e) {}
  }

  function chainEntry(ctx) {
    const data = filterMap.get(ctx);
    return data ? data.filters[0] : ctx.destination;
  }

  function makeDummy() {
    return { connect() {}, disconnect() {} };
  }

  // Patch the constructor
  const PatchedCtx = function (...args) {
    const ctx = new OrigAudioContext(...args);
    buildChain(ctx);
    const _mes = ctx.createMediaElementSource.bind(ctx);
    ctx.createMediaElementSource = function (el) {
      const src = _mes(el);
      try {
        src.connect(chainEntry(ctx));
      } catch (e) {}
      return makeDummy();
    };
    const _mss = ctx.createMediaStreamSource.bind(ctx);
    ctx.createMediaStreamSource = function (stream) {
      const src = _mss(stream);
      try {
        src.connect(chainEntry(ctx));
      } catch (e) {}
      return makeDummy();
    };
    return ctx;
  };
  PatchedCtx.prototype = OrigAudioContext.prototype;

  try {
    Object.defineProperty(window, "AudioContext", {
      value: PatchedCtx,
      writable: true,
      configurable: true,
    });
    Object.defineProperty(window, "webkitAudioContext", {
      value: PatchedCtx,
      writable: true,
      configurable: true,
    });
  } catch (e) {}

  // Hook HTMLMediaElement.play (catches YouTube's native <video>)
  const _play = HTMLMediaElement.prototype.play;
  HTMLMediaElement.prototype.play = function (...a) {
    attachToMediaEl(this);
    return _play.apply(this, a);
  };

  function attachToMediaEl(el) {
    if (el.__eqAttached) return;
    el.__eqAttached = true;
    try {
      const ctx = new OrigAudioContext();
      buildChain(ctx);
      const src = ctx.createMediaElementSource(el);
      src.connect(chainEntry(ctx));
    } catch (e) {
      el.__eqAttached = false;
    }
  }

  // MutationObserver to catch <video>/<audio> added dynamically (YouTube SPA)
  function watchDOM() {
    new MutationObserver((muts) => {
      for (const m of muts) {
        for (const n of m.addedNodes) {
          if (n.nodeType !== 1) continue;
          if (n.matches("video,audio")) attachToMediaEl(n);

          if (n.querySelectorAll) {
            n.querySelectorAll("video,audio").forEach(attachToMediaEl);
          }
        }
      }
    }).observe(document.documentElement, { childList: true, subtree: true });

    document.querySelectorAll("video,audio").forEach(attachToMediaEl);
  }

  if (document.documentElement) watchDOM();
  else document.addEventListener("DOMContentLoaded", watchDOM);

  /* ── SYNC ────────────────────────────────────────────── */
  function syncAll() {
    for (const ctx of allCtx) {
      const d = filterMap.get(ctx);
      if (!d) continue;
      for (let i = 0; i < d.filters.length; i++) {
        d.filters[i].gain.value = eqEnabled ? gains[i] : 0;
      }
      d.gainNode.gain.value = eqEnabled ? masterGain : 1;
    }
  }

  /* ── UI (Shadow DOM — invisible to the page) ─────────── */
  let panelOpen = false;
  let panelLocked = true;
  let shadowHost = null;
  let panelEl = null;
  let sliders = [];
  let valLabels = [];

  function h(tag, props = {}) {
    const el = document.createElement(tag);
    Object.assign(el, props);
    return el;
  }

  function updateValLabel(i, v) {
    const lbl = valLabels[i];
    if (!lbl) return;
    lbl.value = (v > 0 ? "+" : "") + parseFloat(v).toFixed(1);
    // Ayu Mirage colors: Green for positive, Red for negative, Cyan for 0
    lbl.style.color = v > 0 ? "#bae67e" : v < 0 ? "#f28779" : "#5ccfe6";
  }

  function buildUI() {
    if (shadowHost) return;

    shadowHost = document.createElement("div");
    Object.assign(shadowHost.style, {
      position: "fixed",
      top: "0",
      left: "0",
      width: "0",
      height: "0",
      zIndex: "2147483647",
      pointerEvents: "none",
    });
    document.documentElement.appendChild(shadowHost);

    const shadow = shadowHost.attachShadow({ mode: "open" });

    const style = document.createElement("style");
    style.textContent = `
        #wrap {
            position: fixed;
            bottom: 24px; right: 24px;
            width: 510px;
            background: #1f2430; /* Ayu Mirage Bg */
            border: 1px solid #242b38; /* Ayu Border */
            border-radius: 18px;
            padding: 20px 22px 16px;
            color: #cbccc6; /* Ayu Fg */
            font-family: 'Segoe UI', system-ui, sans-serif;
            font-size: 13px;
            box-shadow: 0 16px 60px rgba(0,0,0,0.5);
            display: none;
            pointer-events: all;
            user-select: none;
        }
        #wrap.open { display: block; animation: popIn .18s ease; }
        @keyframes popIn { from { opacity:0; transform: translateY(10px) scale(.97); } }

        #hdr { display:flex; align-items:center; gap:10px; margin-bottom:14px; cursor:default; }
        #hdr:active { cursor: default; }
        #hdr.draggable { cursor:grab; }
        #hdr.draggable:active { cursor:grabbing; }
        #title { font-weight:700; font-size:14px; flex:1; letter-spacing:.3px; }
        #lock-btn {
            font-size: 14px; cursor: pointer; opacity: 0.5;
            transition: opacity 0.15s; padding: 2px 4px;
        }
        #lock-btn:hover { opacity: 1; }
        .kbd {
            font-size:11px; color:#707a8c;
            background:#191e2a;
            border:1px solid #242b38;
            border-radius:6px; padding:2px 8px;
        }
        .row { display:flex; align-items:center; gap:8px; }
        .muted { font-size:12px; color:#707a8c; white-space:nowrap; }
        input[type=checkbox] { accent-color:#5ccfe6; width:15px; height:15px; cursor:pointer; }

        #presets { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:16px; }
        .pb {
            background:#191e2a;
            border:1px solid #242b38;
            border-radius:20px; color:#cbccc6;
            padding:3px 11px; font-size:11px; cursor:pointer; transition:background .1s;
        }
        .pb:hover { background:#242b38; }
        .pb.on { background:rgba(92, 207, 230, 0.15); border-color:#5ccfe6; color:#5ccfe6; }

        #bands { display:flex; gap:3px; align-items:flex-end; margin-bottom:2px; }
        .band { display:flex; flex-direction:column; align-items:center; gap:5px; flex:1; }
        
        .val { 
            font-size:11px; color:#5ccfe6; font-weight:600; text-align:center; 
            background: transparent; border: 1px solid transparent; width: 36px;
            outline: none; padding: 2px 0; font-family: inherit; transition: background 0.1s;
        }
        .val:focus { background: #242b38; border-radius: 4px; }
        .val::selection { background: rgba(92, 207, 230, 0.4); }

        .sl-wrap { height:130px; display:flex; align-items:center; justify-content:center; }
        
        /* Specific selector to ONLY hit the EQ band sliders, not master */
        .sl-wrap input[type=range] {
            writing-mode: vertical-lr; direction: rtl;
            -webkit-appearance: slider-vertical; appearance: slider-vertical;
            width:26px; height:118px; cursor:pointer; accent-color:#5ccfe6;
        }
        .blbl { font-size:11px; color:#707a8c; }

        #bot {
            display:flex; align-items:center; gap:10px;
            margin-top:14px; border-top:1px solid #242b38; padding-top:13px;
        }
        
        /* Master Volume explicitly overrides vertical styles */
        #mvol { 
            flex:1; accent-color:#d4bfff; /* Ayu Purple */ cursor:pointer;
            -webkit-appearance: auto; appearance: auto;
            writing-mode: horizontal-tb; direction: ltr;
            height: auto; width: auto; 
        }
        
        #mval { 
            font-size:12px; color:#d4bfff; font-weight:600; text-align:right;
            background: transparent; border: 1px solid transparent; width: 44px;
            outline: none; padding: 2px 0; font-family: inherit; transition: background 0.1s;
        }
        #mval:focus { background: #242b38; border-radius: 4px; }
        #mval::selection { background: rgba(212, 191, 255, 0.4); }

        #rst {
            background:rgba(242, 135, 121, 0.1); border:1px solid rgba(242, 135, 121, 0.25);
            border-radius:8px; color:#f28779; padding:4px 12px; font-size:11px; cursor:pointer;
        }
        #rst:hover { background:rgba(242, 135, 121, 0.2); }
        `;
    shadow.appendChild(style);

    panelEl = h("div", { id: "wrap" });

    // Header
    const hdr = h("div", { id: "hdr" });
    const lockBtn = h("span", { id: "lock-btn", textContent: "🔒" });
    const title = h("span", { id: "title", textContent: "Equalizer" });
    const kbd = h("span", { className: "kbd", textContent: "Alt + X" });
    const eqRow = h("div", { className: "row" });
    const eqLbl = h("span", { className: "muted", textContent: "EQ" });
    const eqChk = h("input", { type: "checkbox" });

    lockBtn.onclick = (e) => {
      e.stopPropagation();
      panelLocked = !panelLocked;
      lockBtn.textContent = panelLocked ? "🔒" : "🔓";
      hdr.classList.toggle("draggable", !panelLocked);
    };

    if (!panelLocked) hdr.classList.add("draggable");

    eqChk.checked = true;
    eqChk.onchange = () => {
      eqEnabled = eqChk.checked;
      syncAll();
    };
    eqRow.append(eqLbl, eqChk);
    hdr.append(lockBtn, title, kbd, eqRow);
    panelEl.append(hdr);

    // Presets
    const presetsDiv = h("div", { id: "presets" });
    const presetBtns = {};
    function clearPresets() {
      Object.values(presetBtns).forEach((b) => b.classList.remove("on"));
    }
    Object.entries(PRESETS).forEach(([name, vals]) => {
      const btn = h("button", { className: "pb", textContent: name });
      presetBtns[name] = btn;
      btn.onclick = () => {
        vals.forEach((v, i) => {
          gains[i] = v;
          sliders[i].value = v;
          updateValLabel(i, v);
        });
        syncAll();
        clearPresets();
        btn.classList.add("on");
      };
      presetsDiv.append(btn);
    });
    panelEl.append(presetsDiv);

    // Band sliders and inputs
    const bandsDiv = h("div", { id: "bands" });
    const stopProp = (e) => e.stopPropagation(); // Helper to stop keys

    BANDS.forEach((band, i) => {
      const col = h("div", { className: "band" });

      const val = h("input", { className: "val", value: "0.0", type: "text" });
      val.addEventListener("keydown", stopProp);
      val.addEventListener("keyup", stopProp);
      val.addEventListener("keypress", stopProp);

      val.addEventListener("change", () => {
        let parsed = parseFloat(val.value);
        if (isNaN(parsed)) parsed = 0;
        parsed = Math.max(-15, Math.min(15, parsed)); // Clamp

        gains[i] = parsed;
        sliders[i].value = parsed;
        updateValLabel(i, parsed);
        syncAll();
        clearPresets();
      });

      const wrap = h("div", { className: "sl-wrap" });
      const sl = h("input", {
        type: "range",
        min: -15,
        max: 15,
        step: 0.5,
        value: 0,
      });
      sl.setAttribute("orient", "vertical");
      sl.oninput = () => {
        gains[i] = parseFloat(sl.value);
        updateValLabel(i, gains[i]);
        syncAll();
        clearPresets();
      };
      const lbl = h("span", { className: "blbl", textContent: band.label });
      wrap.append(sl);
      col.append(val, wrap, lbl);
      bandsDiv.append(col);
      sliders.push(sl);
      valLabels.push(val);
    });
    panelEl.append(bandsDiv);

    // Bottom (Master Volume)
    const bot = h("div", { id: "bot" });
    const mLbl = h("span", { className: "muted", textContent: "Master" });
    const mSl = h("input", {
      type: "range",
      id: "mvol",
      min: 0,
      max: 2,
      step: 0.01,
      value: 1,
    });

    // Converted master volume text to input field
    const mVal = h("input", { id: "mval", value: "100%", type: "text" });

    mVal.addEventListener("keydown", stopProp);
    mVal.addEventListener("keyup", stopProp);
    mVal.addEventListener("keypress", stopProp);

    mVal.addEventListener("change", () => {
      let parsed = parseFloat(mVal.value.replace("%", ""));
      if (isNaN(parsed)) parsed = masterGain * 100; // fallback

      parsed = Math.max(0, Math.min(200, parsed));

      masterGain = parsed / 100;
      mSl.value = masterGain;
      mVal.value = Math.round(parsed) + "%";
      syncAll();
    });

    const rst = h("button", { id: "rst", textContent: "Reset all" });
    mSl.oninput = () => {
      masterGain = parseFloat(mSl.value);
      mVal.value = Math.round(masterGain * 100) + "%";
      syncAll();
    };
    rst.onclick = () => {
      gains.fill(0);
      sliders.forEach((s, i) => {
        s.value = 0;
        updateValLabel(i, 0);
      });
      masterGain = 1;
      mSl.value = 1;
      mVal.value = "100%";
      syncAll();
      clearPresets();
    };
    bot.append(mLbl, mSl, mVal, rst);
    panelEl.append(bot);
    shadow.appendChild(panelEl);

    // Drag
    let dx = 0,
      dy = 0,
      drag = false;
    hdr.addEventListener("mousedown", (e) => {
      if (panelLocked) return;
      drag = true;
      const r = panelEl.getBoundingClientRect();
      dx = e.clientX - r.left;
      dy = e.clientY - r.top;
    });
    document.addEventListener("mousemove", (e) => {
      if (!drag) return;
      panelEl.style.left = e.clientX - dx + "px";
      panelEl.style.top = e.clientY - dy + "px";
      panelEl.style.right = "auto";
      panelEl.style.bottom = "auto";
    });
    document.addEventListener("mouseup", () => {
      drag = false;
    });
  }

  function togglePanel() {
    if (!shadowHost) buildUI();
    panelOpen = !panelOpen;
    panelEl.classList.toggle("open", panelOpen);
  }

  /* Alt+X shortcut */
  document.addEventListener(
    "keydown",
    (e) => {
      if (e.altKey && e.key.toLowerCase() === "x") {
        e.preventDefault();
        e.stopPropagation();
        togglePanel();
      }
    },
    false,
  );

  /* Build UI as soon as <body> exists */
  function tryBuild() {
    if (document.body) buildUI();
  }
  if (document.body) buildUI();
  else {
    new MutationObserver((_, obs) => {
      if (document.body) {
        obs.disconnect();
        buildUI();
      }
    }).observe(document.documentElement, { childList: true });
  }
})();