GOTOUkun510 Typing Equalizer

gotoukun510.vercel.app専用イコライザー(navボタン→popover版)

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         GOTOUkun510 Typing Equalizer
// @namespace    https://gotoukun510.vercel.app/
// @version      5.0.1
// @description  gotoukun510.vercel.app専用イコライザー(navボタン→popover版)
// @author       you
// @match        https://gotoukun510.vercel.app/*
// @include      https://www.youtube.com/embed/*origin=https%3A%2F%2Fgotoukun510.vercel.app*
// @grant        none
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const IS_YOUTUBE = location.hostname === 'www.youtube.com';
  const SITE_ORIGIN = 'https://gotoukun510.vercel.app';

  // ================================================================
  // 共通:プリセット定義
  // 32Hz, 64Hz, 125Hz, 250Hz, 500Hz, 1kHz, 2kHz, 4kHz, 8kHz, 16kHz
  // ================================================================
  const PRESETS = {
    hard_rock:  [ 8,  9,  7,  3,  0, -4, -2,  4,  7,  8],
    extreme:    [12, 13, 11,  6,  1, -6, -3,  6, 11, 13],
    pop:        [ 2,  3,  4,  5,  4,  3,  3,  4,  5,  4],
    electronic: [ 9,  8,  5,  0, -3, -5,  2,  6,  9, 11],
    synth:      [13, 12,  7, -1, -5, -8,  3,  8, 12, 14],
    rnb:        [ 8,  9,  8,  4,  2,  2,  2,  4,  5,  5],
    metal:      [ 8,  9,  6,  1, -2, -6, -2,  5,  8, 10],
    acoustic:   [ 3,  4,  4,  3,  3,  4,  5,  5,  6,  5],
    latin:      [ 5,  6,  5,  3,  1,  1,  3,  5,  6,  7],
    reggae:     [ 9, 10,  7,  2, -1,  3,  1,  0,  3,  3],
    country:    [ 3,  4,  4,  3,  2,  3,  5,  6,  7,  6],
    night_mode: [ 2,  3,  5,  6,  6,  5,  4,  2,  1, -1],
    vocal:      [-2, -1,  1,  3,  5,  8,  7,  5,  3,  1],
    rhythm:     [ 5,  8,  5, -3, -4, -2,  3,  5,  6,  4],
    deep_bass:  [15, 14, 11,  6,  1, -2, -3, -2, -1,  0],
    donshari:   [14, 13,  9,  2, -4,-12, -6,  4, 11, 14],
    flat:       [ 0,  0,  0,  0,  0,  0,  0,  0,  0,  0],
    // 立体音響系
    spatial:    [-1,  0,  1, -2, -3,  2,  5,  6,  9, 10],
    surround:   [ 4,  5,  3, -1, -4,  1,  4,  7,  8,  6],
    air:        [-2, -1,  0, -3, -2,  1,  4,  8, 11, 13],
  };

  // ================================================================
  // Audio Engine(YouTube embed側)
  // ================================================================
  if (IS_YOUTUBE) {
    const EQ_BANDS = [
      { type: 'lowshelf',  freq: 32,    q: 1.0 },
      { type: 'peaking',   freq: 64,    q: 1.4 },
      { type: 'peaking',   freq: 125,   q: 1.4 },
      { type: 'peaking',   freq: 250,   q: 1.4 },
      { type: 'peaking',   freq: 500,   q: 1.4 },
      { type: 'peaking',   freq: 1000,  q: 1.4 },
      { type: 'peaking',   freq: 2000,  q: 1.4 },
      { type: 'peaking',   freq: 4000,  q: 1.4 },
      { type: 'peaking',   freq: 8000,  q: 1.4 },
      { type: 'highshelf', freq: 16000, q: 1.0 },
    ];

    let audioCtx = null, filters = [], gainNode = null;
    let connected = false, eqEnabled = true, currentPreset = 'rhythm';

    function buildEQ(ctx) {
      audioCtx = ctx;
      gainNode = ctx.createGain();
      gainNode.gain.value = 1.0;
      filters = EQ_BANDS.map((band, i) => {
        const f = ctx.createBiquadFilter();
        f.type = band.type;
        f.frequency.value = band.freq;
        f.Q.value = band.q;
        f.gain.value = PRESETS[currentPreset][i];
        return f;
      });
      gainNode.connect(filters[0]);
      filters.reduce((a, b) => { a.connect(b); return b; });
      filters[filters.length - 1].connect(ctx.destination);
    }

    function connectVideo(video) {
      if (connected) return;
      try {
        const ctx = new AudioContext();
        buildEQ(ctx);
        let source;
        try { source = ctx.createMediaElementSource(video); }
        catch (e) {
          if (e.name === 'InvalidStateError') { notifyParent({ type: 'YTEQ_ERROR', message: 'already_connected' }); return; }
          throw e;
        }
        source.connect(gainNode);
        connected = true;
        notifyParent({ type: 'YTEQ_CONNECTED' });
        const tryResume = () => { if (ctx.state === 'suspended') ctx.resume(); };
        if (!video.paused) tryResume();
        video.addEventListener('play', tryResume);
        document.addEventListener('click', tryResume, { once: true });
      } catch (e) { notifyParent({ type: 'YTEQ_ERROR', message: e.message }); }
    }

    function waitForVideo() {
      const v = document.querySelector('video');
      if (v) { connectVideo(v); return; }
      const obs = new MutationObserver(() => {
        const v2 = document.querySelector('video');
        if (v2) { obs.disconnect(); connectVideo(v2); }
      });
      obs.observe(document.documentElement, { childList: true, subtree: true });
    }

    function setEnabled(enabled) {
      eqEnabled = enabled;
      const t = audioCtx?.currentTime ?? 0;
      filters.forEach((f, i) => f.gain.setTargetAtTime(enabled ? PRESETS[currentPreset][i] : 0, t, 0.02));
      notifyParent({ type: 'YTEQ_TOGGLED', enabled });
    }

    function applyPreset(name) {
      if (!PRESETS[name]) return;
      currentPreset = name;
      if (!eqEnabled) return;
      const t = audioCtx?.currentTime ?? 0;
      PRESETS[name].forEach((g, i) => { if (filters[i]) filters[i].gain.setTargetAtTime(g, t, 0.02); });
      notifyParent({ type: 'YTEQ_PRESET_CHANGED', preset: name });
    }

    function setVolume(v) {
      if (!gainNode) return;
      gainNode.gain.setTargetAtTime(v, audioCtx?.currentTime ?? 0, 0.02);
    }

    function notifyParent(data) { window.parent.postMessage(data, SITE_ORIGIN); }

    window.addEventListener('message', (e) => {
      if (e.origin !== SITE_ORIGIN) return;
      if (!e.data || e.data.namespace !== 'YTEQ') return;
      const { type, bandIndex, gain } = e.data;
      if (type === 'TOGGLE')    setEnabled(!eqEnabled);
      if (type === 'SET_ENABLED') setEnabled(!!e.data.enabled);
      if (type === 'SET_VOLUME')  setVolume(e.data.value);
      if (type === 'SET_PRESET')  applyPreset(e.data.preset);
      if (type === 'SET_GAIN' && filters[bandIndex] !== undefined)
        filters[bandIndex].gain.setTargetAtTime(gain, audioCtx?.currentTime ?? 0, 0.01);
    });

    document.readyState === 'loading'
      ? document.addEventListener('DOMContentLoaded', waitForVideo)
      : waitForVideo();
    return;
  }

  // ================================================================
  // UI(gotoukun510.vercel.app側)
  // ================================================================

  const POPOVER_ID = 'yteq-popover';
  const NAV_BTN_ID = 'yteq-nav-btn';
  const LS_KEY        = 'yteq_settings';
  const LS_CUSTOM_KEY = 'yteq_custom_presets';

  const PRESET_LABELS = {
    rhythm:     'RHYTHM',   hard_rock: 'ROCK',    extreme: 'EXTREME',
    pop:        'POP',      electronic: 'ELEC',   synth: 'SYNTH',
    rnb:        'R&B',      metal: 'METAL',       acoustic: 'ACOUS',
    latin:      'LATIN',    reggae: 'REGGAE',     country: 'CNTRY',
    night_mode: 'NIGHT',    vocal: 'VOCAL',       deep_bass: 'DEEP',
    donshari:   'ドンシャリ', flat: 'FLAT',
    spatial:    '立体音響',   surround: 'サラウンド', air: 'エアー',
  };
  const PRESET_KEYS = Object.keys(PRESET_LABELS);
  const BAND_LABELS = ['32Hz','64Hz','125Hz','250Hz','500Hz','1kHz','2kHz','4kHz','8kHz','16kHz'];

  // ── 状態 ──────────────────────────────────────────────────
  let eqConnected       = false;
  let eqEnabled         = true;
  let currentPreset     = 'rhythm';
  let currentGains      = [...PRESETS['rhythm']];
  let currentVolume     = 100;
  let customPresets     = [];
  let lastSelectedPreset = 'rhythm';
  let popoverOpen       = false;

  // ── localStorage ──────────────────────────────────────────
  function saveSettings() {
    try {
      localStorage.setItem(LS_KEY, JSON.stringify({
        preset: currentPreset, gains: currentGains,
        volume: currentVolume, enabled: eqEnabled,
        lastSelectedPreset,
      }));
    } catch (e) {}
  }

  function loadSettings() {
    try {
      const s = JSON.parse(localStorage.getItem(LS_KEY));
      if (!s) {
        currentPreset = 'rhythm';
        currentGains  = [...PRESETS['rhythm']];
        lastSelectedPreset = 'rhythm';
        return;
      }
      if (s.preset && PRESETS[s.preset])                   currentPreset        = s.preset;
      if (Array.isArray(s.gains) && s.gains.length === 10) currentGains         = s.gains;
      if (typeof s.volume  === 'number')                   currentVolume        = s.volume;
      if (typeof s.enabled === 'boolean')                  eqEnabled            = s.enabled;
      if (typeof s.lastSelectedPreset === 'string' &&
          (PRESETS[s.lastSelectedPreset] || s.lastSelectedPreset.startsWith('custom:')))
        lastSelectedPreset = s.lastSelectedPreset;
      else
        lastSelectedPreset = currentPreset || 'rhythm';
    } catch (e) {
      currentPreset = 'rhythm';
      currentGains  = [...PRESETS['rhythm']];
      lastSelectedPreset = 'rhythm';
    }
  }

  function saveCustom() {
    try { localStorage.setItem(LS_CUSTOM_KEY, JSON.stringify(customPresets)); } catch (e) {}
  }

  function loadCustom() {
    try {
      const c = JSON.parse(localStorage.getItem(LS_CUSTOM_KEY));
      if (Array.isArray(c)) customPresets = c;
    } catch (e) {}
  }

  loadCustom();
  loadSettings();

  // ── embed 送信 ─────────────────────────────────────────────
  function sendToEmbed(data) {
    const iframe = document.querySelector('iframe[src*="youtube.com/embed"]');
    if (iframe) iframe.contentWindow.postMessage({ namespace: 'YTEQ', ...data }, 'https://www.youtube.com');
  }

  // ── postMessage 受信 ───────────────────────────────────────
  window.addEventListener('message', (e) => {
    if (e.origin !== 'https://www.youtube.com') return;
    if (!e.data) return;
    if (e.data.type === 'YTEQ_CONNECTED') {
      eqConnected = true;
      syncStatus(true);
      sendToEmbed({ type: 'SET_PRESET', preset: currentPreset });
      sendToEmbed({ type: 'SET_VOLUME', value: currentVolume / 100 });
      if (!eqEnabled) sendToEmbed({ type: 'SET_ENABLED', enabled: false });
      const base = PRESETS[currentPreset];
      if (!base || !base.every((v, i) => v === currentGains[i]))
        currentGains.forEach((g, i) => sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g }));
    }
    if (e.data.type === 'YTEQ_ERROR')          syncStatus(false);
    if (e.data.type === 'YTEQ_TOGGLED')        { eqEnabled = e.data.enabled; syncToggle(); }
    if (e.data.type === 'YTEQ_PRESET_CHANGED') { currentPreset = e.data.preset; syncPresets(); }
  });

  // ================================================================
  // アクション(状態変更の単一窓口)
  // ================================================================
  function doToggle() {
    eqEnabled = !eqEnabled;
    sendToEmbed({ type: 'TOGGLE' });
    syncToggle();
    saveSettings();
  }

  function doSelectPreset(key) {
    currentPreset = lastSelectedPreset = key;
    currentGains  = [...PRESETS[key]];
    sendToEmbed({ type: 'SET_PRESET', preset: key });
    syncPresets(); syncSliders(); syncReset();
    saveSettings();
  }

  function doBandChange(i, g) {
    currentGains[i] = g;
    syncBandVal(i, g);
    sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g });
    syncReset();
    saveSettings();
  }

  function doSetVolume(pct) {
    currentVolume = pct;
    syncVolLabel();
    sendToEmbed({ type: 'SET_VOLUME', value: pct / 100 });
    saveSettings();
  }

  function doReset() {
    if (lastSelectedPreset.startsWith('custom:')) {
      const p = customPresets[parseInt(lastSelectedPreset.split(':')[1])];
      if (p) currentGains = [...p.gains];
    } else if (PRESETS[lastSelectedPreset]) {
      currentPreset = lastSelectedPreset;
      currentGains  = [...PRESETS[lastSelectedPreset]];
      sendToEmbed({ type: 'SET_PRESET', preset: lastSelectedPreset });
    }
    syncSliders(); syncPresets(); syncReset();
    saveSettings();
  }

  function doLoadCustom(idx) {
    const p = customPresets[idx];
    if (!p) return;
    currentGains = [...p.gains];
    currentPreset = '';
    lastSelectedPreset = `custom:${idx}`;
    p.gains.forEach((g, i) => sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g }));
    syncPresets(); syncSliders(); syncReset();
    renderCustomList();
    saveSettings();
  }

  function doDelCustom(idx) {
    customPresets.splice(idx, 1);
    saveCustom();
    renderCustomList();
  }

  function doSaveCustom() {
    const name = prompt('プリセット名を入力:');
    if (!name?.trim()) return;
    customPresets.push({ name: name.trim(), gains: [...currentGains] });
    saveCustom();
    renderCustomList();
  }

  // ================================================================
  // 同期ヘルパー
  // ================================================================
  function syncStatus(ok) {
    eqConnected = ok;
    const s = document.getElementById('yteq-status');
    if (s) s.textContent = ok ? '🟢 Connected' : '⚪ Disconnected';
    // nav ボタンに接続状態を dot で反映
    const b = document.getElementById(NAV_BTN_ID);
    if (b) b.dataset.connected = ok ? '1' : '0';
  }

  function syncToggle() {
    const btn = document.getElementById('yteq-toggle-btn');
    if (!btn) return;
    btn.textContent = eqEnabled ? 'ON' : 'OFF';
    btn.classList.toggle('off', !eqEnabled);
    // nav ボタンの色も連動
    const nb = document.getElementById(NAV_BTN_ID);
    if (nb) nb.classList.toggle('eq-on', eqEnabled);
  }

  function syncPresets() {
    document.querySelectorAll('[data-yteq-preset]').forEach(btn => {
      btn.classList.toggle('yteq-active', btn.dataset.yteqPreset === lastSelectedPreset);
    });
  }

  function syncBandVal(i, g) {
    const sign = g > 0 ? '+' : '';
    const cls  = g > 0 ? 'pos' : g < 0 ? 'neg' : '';
    const v = document.getElementById(`yteq-val-${i}`);
    if (v) { v.textContent = `${sign}${g}`; v.className = `yteq-bval ${cls}`; }
    const s = document.getElementById(`yteq-slider-${i}`);
    if (s) s.value = g;
  }

  function syncSliders() {
    BAND_LABELS.forEach((_, i) => syncBandVal(i, currentGains[i]));
  }

  function syncReset() {
    let base = null;
    if (lastSelectedPreset.startsWith('custom:')) {
      base = customPresets[parseInt(lastSelectedPreset.split(':')[1])]?.gains ?? null;
    } else if (PRESETS[lastSelectedPreset]) {
      base = PRESETS[lastSelectedPreset];
    }
    const modified = base && !base.every((v, i) => v === currentGains[i]);
    const btn = document.getElementById('yteq-eq-reset');
    if (btn) btn.classList.toggle('hidden', !modified);
  }

  function syncVolLabel() {
    const v = document.getElementById('yteq-volume-val');
    if (v) v.textContent = `${currentVolume}%`;
    const s = document.getElementById('yteq-volume-slider');
    if (s) s.value = currentVolume;
  }

  function renderCustomList() {
    const list = document.getElementById('yteq-custom-list');
    if (!list) return;
    if (customPresets.length === 0) {
      list.innerHTML = '<div id="yteq-custom-empty">No saved presets</div>';
      return;
    }
    list.innerHTML = customPresets.map((p, idx) => `
      <div class="yteq-custom-item">
        <button class="yteq-preset-btn" data-yteq-preset="custom:${idx}" data-idx="${idx}">${p.name}</button>
        <button class="yteq-custom-del" data-idx="${idx}">✕</button>
      </div>
    `).join('');
    list.querySelectorAll('[data-idx]').forEach(btn => {
      const idx = parseInt(btn.dataset.idx);
      if (btn.classList.contains('yteq-preset-btn'))
        btn.addEventListener('click', () => doLoadCustom(idx));
      else
        btn.addEventListener('click', () => doDelCustom(idx));
    });
    syncPresets();
  }

  // ================================================================
  // Popover
  // ================================================================
  function buildPopover() {
    if (document.getElementById(POPOVER_ID)) return;

    const slidersHTML = BAND_LABELS.map((label, i) => {
      const g    = currentGains[i];
      const sign = g > 0 ? '+' : '';
      const cls  = g > 0 ? 'pos' : g < 0 ? 'neg' : '';
      return `<div class="yteq-band">
        <span class="yteq-bval ${cls}" id="yteq-val-${i}">${sign}${g}</span>
        <input class="yteq-band-slider" type="range"
          id="yteq-slider-${i}" min="-15" max="15" step="0.5" value="${g}">
        <span class="yteq-blabel">${label}</span>
      </div>`;
    }).join('');

    const presetsHTML = PRESET_KEYS.map(key =>
      `<button class="yteq-preset-btn" data-yteq-preset="${key}">${PRESET_LABELS[key]}</button>`
    ).join('');

    const pop = document.createElement('div');
    pop.id = POPOVER_ID;
    pop.innerHTML = `
      <div id="yteq-header">
        <span id="yteq-title">YTyping EQ</span>
        <span id="yteq-status">${eqConnected ? '🟢 Connected' : '⚪ Disconnected'}</span>
        <button id="yteq-toggle-btn" class="${eqEnabled ? '' : 'off'}">${eqEnabled ? 'ON' : 'OFF'}</button>
      </div>
      <div id="yteq-body">
        <div class="yteq-group-label">PRESET</div>
        <div class="yteq-preset-grid">${presetsHTML}</div>
        <div id="yteq-eq-section">
          <div id="yteq-eq-header">
            <span class="yteq-group-label">BANDS</span>
            <button id="yteq-eq-reset" class="hidden">RESET</button>
          </div>
          <div id="yteq-eq-sliders">${slidersHTML}</div>
          <div id="yteq-volume-row">
            <span>🔊 VOL</span>
            <input id="yteq-volume-slider" type="range" min="0" max="200" step="1" value="${currentVolume}">
            <span id="yteq-volume-val">${currentVolume}%</span>
          </div>
        </div>
        <div id="yteq-custom-section">
          <div id="yteq-custom-header">
            <span class="yteq-group-label">CUSTOM</span>
            <button id="yteq-custom-save">+ Save current</button>
          </div>
          <div id="yteq-custom-list"></div>
        </div>
      </div>
    `;

    document.body.appendChild(pop);

    // イベント
    pop.querySelector('#yteq-toggle-btn').addEventListener('click', doToggle);
    pop.querySelector('#yteq-eq-reset').addEventListener('click', doReset);
    pop.querySelector('#yteq-custom-save').addEventListener('click', doSaveCustom);
    pop.querySelector('#yteq-volume-slider').addEventListener('input', function () {
      doSetVolume(parseInt(this.value));
    });
    pop.querySelectorAll('[data-yteq-preset]').forEach(btn => {
      btn.addEventListener('click', () => doSelectPreset(btn.dataset.yteqPreset));
    });
    BAND_LABELS.forEach((_, i) => {
      pop.querySelector(`#yteq-slider-${i}`).addEventListener('input', function () {
        doBandChange(i, parseFloat(this.value));
      });
    });

    renderCustomList();
    syncPresets();
    syncReset();

    // popover 外クリックで閉じる
    document.addEventListener('mousedown', (e) => {
      if (!popoverOpen) return;
      const pop = document.getElementById(POPOVER_ID);
      const btn = document.getElementById(NAV_BTN_ID);
      if (pop && !pop.contains(e.target) && btn && !btn.contains(e.target)) {
        closePopover();
      }
    }, true);
  }

  function positionPopover() {
    const pop = document.getElementById(POPOVER_ID);
    const btn = document.getElementById(NAV_BTN_ID);
    if (!pop || !btn) return;

    const navEl  = document.querySelector('nav');
    const btnRect = btn.getBoundingClientRect();
    const navRect = navEl ? navEl.getBoundingClientRect() : null;

    // nav が左サイドバーの場合 → nav の右端に表示
    // nav が上部の場合 → ボタン直下に表示
    const navIsVertical = navEl && navEl.offsetWidth < navEl.offsetHeight;

    pop.style.position = 'fixed';
    pop.style.zIndex   = '999999';

    if (navIsVertical && navRect) {
      // 左サイドバー → 右隣
      pop.style.left   = `${navRect.right + 8}px`;
      pop.style.top    = `${Math.min(btnRect.top, window.innerHeight - pop.offsetHeight - 12)}px`;
      pop.style.right  = 'auto';
      pop.style.bottom = 'auto';
    } else {
      // 上部ナビ → ボタン下
      const left = Math.min(btnRect.left, window.innerWidth - pop.offsetWidth - 12);
      pop.style.left   = `${Math.max(8, left)}px`;
      pop.style.top    = `${btnRect.bottom + 8}px`;
      pop.style.right  = 'auto';
      pop.style.bottom = 'auto';
    }
  }

  function openPopover() {
    buildPopover();
    const pop = document.getElementById(POPOVER_ID);
    pop.classList.remove('hidden');
    popoverOpen = true;
    // 位置は次フレームで確定させる(offsetHeight を取るため)
    requestAnimationFrame(positionPopover);
    document.getElementById(NAV_BTN_ID)?.classList.add('active');
  }

  function closePopover() {
    const pop = document.getElementById(POPOVER_ID);
    if (pop) pop.classList.add('hidden');
    popoverOpen = false;
    document.getElementById(NAV_BTN_ID)?.classList.remove('active');
  }

  function togglePopover() {
    popoverOpen ? closePopover() : openPopover();
  }

  // ================================================================
  // nav ボタン挿入
  // ================================================================
  function insertNavBtn() {
    if (document.getElementById(NAV_BTN_ID)) return;
    const nav = document.querySelector('nav');
    if (!nav) return;

    const btn = document.createElement('button');
    btn.id = NAV_BTN_ID;
    btn.innerHTML = `
      <svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="display:block;">
        <rect x="2"  y="14" width="3" height="8"  rx="1" fill="currentColor" opacity="0.7"/>
        <rect x="7"  y="8"  width="3" height="14" rx="1" fill="currentColor" opacity="0.85"/>
        <rect x="12" y="4"  width="3" height="18" rx="1" fill="currentColor"/>
        <rect x="17" y="10" width="3" height="12" rx="1" fill="currentColor" opacity="0.85"/>
      </svg>
      <span>EQ</span>
    `;
    btn.addEventListener('click', (e) => { e.stopPropagation(); togglePopover(); });

    // right-nav-icons があればそこへ、なければ nav 末尾へ
    const icons = document.getElementById('right-nav-icons');
    if (icons) icons.prepend(btn);
    else nav.appendChild(btn);

    if (eqEnabled) btn.classList.add('eq-on');
  }

  // ================================================================
  // スタイル
  // ================================================================
  function injectStyles() {
    if (document.getElementById('yteq-styles')) return;
    const s = document.createElement('style');
    s.id = 'yteq-styles';
    s.textContent = `
      /* ── nav ボタン ────────────────────────────────── */
      #yteq-nav-btn {
        display: inline-flex; align-items: center; gap: 5px;
        padding: 4px 10px; border-radius: 4px; cursor: pointer;
        font-size: 12px; font-weight: 700; letter-spacing: 0.05em;
        color: #aaaacc; border: 1px solid transparent;
        background: transparent; transition: all 0.15s;
        font-family: 'JetBrains Mono','Consolas',monospace;
      }
      #yteq-nav-btn:hover  { color: #ffffff; background: rgba(255,255,255,0.08); }
      #yteq-nav-btn.active { color: #9999ff; border-color: #5555bb; background: #1e1e3a; }
      #yteq-nav-btn.eq-on  { color: #66cc66; }
      #yteq-nav-btn.eq-on.active { color: #66cc66; border-color: #3a6a3a; background: #1a2a1a; }

      /* ── Popover 本体 ───────────────────────────────── */
      #yteq-popover {
        position: fixed; z-index: 999999;
        background: #0f0f14; border: 1px solid #2a2a3a; border-radius: 10px;
        font-family: 'JetBrains Mono','Consolas',monospace; font-size: 11px;
        color: #c8c8e0; box-shadow: 0 8px 32px rgba(0,0,0,0.7);
        user-select: none; width: 340px;
        transition: opacity 0.15s, transform 0.15s;
        transform-origin: top left;
      }
      #yteq-popover.hidden {
        opacity: 0; pointer-events: none; transform: scale(0.97) translateY(-4px);
      }

      /* ── ヘッダー ───────────────────────────────────── */
      #yteq-header {
        display: flex; align-items: center; gap: 8px; padding: 8px 12px;
        border-bottom: 1px solid #2a2a3a;
      }
      #yteq-title  { font-weight: 700; font-size: 12px; flex: 1; letter-spacing: 0.05em; }
      #yteq-status { font-size: 10px; opacity: 0.7; }
      #yteq-toggle-btn {
        background: #1a2a1a; border: 1px solid #3a6a3a; color: #66cc66;
        border-radius: 4px; padding: 2px 8px; cursor: pointer;
        font-size: 10px; font-family: inherit; font-weight: 700;
        letter-spacing: 0.05em; transition: all 0.15s;
      }
      #yteq-toggle-btn.off { background: #1a1a1a; border-color: #3a3a3a; color: #555; }

      /* ── ボディ ─────────────────────────────────────── */
      #yteq-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
      .yteq-group-label { font-size: 9px; color: #4a4a6a; letter-spacing: 0.08em; margin-bottom: 3px; }

      /* ── プリセットグリッド ─────────────────────────── */
      .yteq-preset-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 3px; }
      .yteq-preset-btn {
        background: #1a1a2a; border: 1px solid #2a2a3a; color: #6a6a9a;
        border-radius: 4px; padding: 4px 2px; cursor: pointer;
        font-size: 9px; font-family: inherit; font-weight: 700;
        letter-spacing: 0.04em; text-align: center; transition: all 0.12s;
        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      }
      .yteq-preset-btn:hover { background: #22223a; color: #9a9acc; border-color: #4a4a6a; }
      .yteq-preset-btn.yteq-active { background: #1e1e3a; border-color: #5555bb; color: #9999ff; }

      /* ── バンドスライダー ───────────────────────────── */
      #yteq-eq-section { border-top: 1px solid #1e1e2e; padding-top: 8px; }
      #yteq-eq-header  { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
      #yteq-eq-reset {
        background: #1a1a2a; border: 1px solid #2a2a3a; color: #6a6a9a;
        border-radius: 3px; padding: 1px 7px; cursor: pointer; font-size: 9px; font-family: inherit;
        transition: all 0.12s;
      }
      #yteq-eq-reset:hover { background: #22223a; color: #9a9acc; }
      #yteq-eq-reset.hidden { display: none; }
      #yteq-eq-sliders { display: flex; gap: 2px; align-items: flex-end; justify-content: space-between; }
      .yteq-band { display: flex; flex-direction: column; align-items: center; gap: 3px; flex: 1; }
      .yteq-bval { font-size: 7px; color: #6a6a8a; min-width: 20px; text-align: center; }
      .yteq-bval.pos { color: #7878cc; }
      .yteq-bval.neg { color: #aa6666; }
      .yteq-band-slider {
        -webkit-appearance: none; writing-mode: vertical-lr; direction: rtl;
        width: 20px; height: 90px; background: transparent; cursor: pointer;
      }
      .yteq-band-slider::-webkit-slider-runnable-track { width: 3px; background: #2a2a3a; border-radius: 2px; }
      .yteq-band-slider::-webkit-slider-thumb {
        -webkit-appearance: none; width: 11px; height: 11px;
        background: #5555cc; border-radius: 50%; border: 2px solid #8888ff; margin-left: -4px;
      }
      .yteq-band-slider::-webkit-slider-thumb:hover { background: #7777ff; }
      .yteq-blabel { font-size: 7px; color: #4a4a6a; text-align: center; }

      /* ── ボリューム ─────────────────────────────────── */
      #yteq-volume-row {
        display: flex; align-items: center; gap: 8px; font-size: 10px; color: #6a6a8a;
        padding-top: 8px; border-top: 1px solid #1e1e2e;
      }
      #yteq-volume-val { min-width: 34px; text-align: right; color: #9a9acc; font-weight: 700; }
      #yteq-volume-slider {
        -webkit-appearance: none; flex: 1; height: 3px;
        background: #2a2a3a; border-radius: 2px; outline: none; cursor: pointer;
      }
      #yteq-volume-slider::-webkit-slider-thumb {
        -webkit-appearance: none; width: 13px; height: 13px;
        background: #6666cc; border-radius: 50%; border: 2px solid #9999ff;
      }
      #yteq-volume-slider::-webkit-slider-thumb:hover { background: #8888ff; }

      /* ── カスタムプリセット ─────────────────────────── */
      #yteq-custom-section { border-top: 1px solid #1e1e2e; padding-top: 8px; }
      #yteq-custom-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
      #yteq-custom-save {
        background: #1a2a1a; border: 1px solid #3a6a3a; color: #66cc66;
        border-radius: 3px; padding: 1px 7px; cursor: pointer;
        font-size: 9px; font-family: inherit; font-weight: 700;
      }
      #yteq-custom-save:hover { background: #223a22; }
      #yteq-custom-list { display: flex; flex-direction: column; gap: 3px; }
      .yteq-custom-item { display: flex; align-items: center; gap: 4px; }
      .yteq-custom-item .yteq-preset-btn { flex: 1; text-align: left; padding: 4px 6px; }
      .yteq-custom-del {
        background: transparent; border: 1px solid #3a2a2a; color: #664444;
        border-radius: 3px; padding: 2px 5px; cursor: pointer;
        font-size: 9px; font-family: inherit; flex-shrink: 0; transition: all 0.12s;
      }
      .yteq-custom-del:hover { background: #2a1a1a; color: #cc6666; border-color: #6a3a3a; }
      #yteq-custom-empty { font-size: 9px; color: #3a3a5a; text-align: center; padding: 4px 0; }
    `;
    document.head.appendChild(s);
  }

  // ================================================================
  // DOM 監視・初期化
  // ================================================================
  function init() {
    injectStyles();
    insertNavBtn();
  }

  function watchDOM() {
    const obs = new MutationObserver(() => {
      if (!document.getElementById(NAV_BTN_ID)) insertNavBtn();
    });
    obs.observe(document.documentElement, { childList: true, subtree: false });
    if (document.body) obs.observe(document.body, { childList: true, subtree: true });
  }

  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', () => { init(); watchDOM(); })
    : (init(), watchDOM());

})();