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 });
  }
})();