GOTOUkun510 Typing Equalizer

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

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

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

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

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

이 스크립트를 설치하려면 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());

})();