YTyping Equalizer

ytyping.net専用イコライザー

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         YTyping Equalizer
// @namespace    https://ytyping.net/
// @version      3.0.2
// @description  ytyping.net専用イコライザー
// @author       you
// @match        https://ytyping.net/*
// @include      https://www.youtube.com/embed/*origin=https%3A%2F%2Fytyping.net*
// @grant        none
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const IS_YOUTUBE = location.hostname === 'www.youtube.com';

  // ================================================================
  // 共通:プリセット定義
  // 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],
  };

  // ================================================================
  // 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;
    let filters = [];
    let gainNode = null;
    let connected = false;
    let eqEnabled = true;
    let 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((prev, curr) => { prev.connect(curr); return curr; });
      filters[filters.length - 1].connect(ctx.destination);
      console.log(`[YTyping EQ] built: ${currentPreset}`);
    }

    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 existing = document.querySelector('video');
      if (existing) { connectVideo(existing); return; }
      const observer = new MutationObserver(() => {
        const video = document.querySelector('video');
        if (video) { observer.disconnect(); connectVideo(video); }
      });
      observer.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(value) {
      if (!gainNode) return;
      gainNode.gain.setTargetAtTime(value, audioCtx?.currentTime ?? 0, 0.02);
    }

    function notifyParent(data) {
      window.parent.postMessage(data, 'https://ytyping.net');
    }

    window.addEventListener('message', (e) => {
      if (e.origin !== 'https://ytyping.net') 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);
      }
    });

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', waitForVideo);
    } else {
      waitForVideo();
    }

    return; // Audio Engine ここまで
  }

  // ================================================================
  // UI(ytyping.net側)
  // ================================================================

  const PANEL_ID = 'yteq-panel';
  const NAV_BTN_ID = 'yteq-nav-btn';
  let panelVisible = false;

  const PRESET_LABELS = {
    hard_rock: 'ROCK',    extreme: 'EXTREME', pop: 'POP',
    electronic: 'ELEC',   synth: 'SYNTH',     rnb: 'R&B',
    metal: 'METAL',       acoustic: 'ACOUS',  latin: 'LATIN',
    reggae: 'REGGAE',     country: 'COUNTRY', night_mode: 'NIGHT',
    vocal: 'VOCAL',       rhythm: 'RHYTHM',   deep_bass: 'DEEP',
    donshari: 'ドンシャリ', flat: 'FLAT',
  };

  const PRESET_GROUPS = [
    { label: null, keys: ['rhythm','hard_rock','extreme','pop','electronic','synth','rnb','metal','acoustic','latin','reggae','country','night_mode','vocal','deep_bass','donshari','flat'] },
  ];

  const BAND_LABELS = ['32Hz','64Hz','125Hz','250Hz','500Hz','1kHz','2kHz','4kHz','8kHz','16kHz'];
  const LS_KEY = 'yteq_settings';
  const LS_CUSTOM_KEY = 'yteq_custom_presets';

  // ─── 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) return;
      if (s.preset && PRESETS[s.preset]) { currentPreset = s.preset; lastSelectedPreset = 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') lastSelectedPreset = s.lastSelectedPreset;
    } catch (e) {}
  }

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

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

  let eqConnected = false;
  let eqEnabled = true;
  let currentPreset = 'rhythm';
  let currentGains = [...PRESETS['rhythm']];
  let currentVolume = 100;
  let customPresets = [];
  let lastSelectedPreset = 'rhythm'; // 最後に選択したプリセットキー('custom:N' or preset key) // [{ name, gains }, ...]

  // 起動時にlocalStorageから復元
  loadCustomPresets();
  loadSettings();

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

  window.addEventListener('message', (e) => {
    if (e.origin !== 'https://www.youtube.com') return;
    if (!e.data) return;
    if (e.data.type === 'YTEQ_CONNECTED') {
      eqConnected = true;
      updateStatus(true);
      // 保存済み設定をembedに送信して反映
      sendToEmbed({ type: 'SET_PRESET', preset: currentPreset });
      sendToEmbed({ type: 'SET_VOLUME', value: currentVolume / 100 });
      if (!eqEnabled) sendToEmbed({ type: 'SET_ENABLED', enabled: false });
      // 個別にgainが変わっている場合(カスタム状態)は各バンドを送信
      const presetGains = PRESETS[currentPreset];
      if (!presetGains.every((v, i) => v === currentGains[i])) {
        currentGains.forEach((g, i) => sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g }));
      }
    }
    if (e.data.type === 'YTEQ_ERROR') { updateStatus(false); }
    if (e.data.type === 'YTEQ_TOGGLED') { eqEnabled = e.data.enabled; updateToggleBtn(); }
    if (e.data.type === 'YTEQ_PRESET_CHANGED') { currentPreset = e.data.preset; updatePresetButtons(); }
  });

  function createUI() {
    if (document.getElementById(PANEL_ID)) return;

    const style = document.createElement('style');
    style.textContent = `
      #yteq-panel {
        position: fixed; bottom: 20px; right: 20px; 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.6);
        user-select: none; width: 340px;
        transition: opacity 0.2s, transform 0.2s;
      }
      #yteq-panel.hidden {
        opacity: 0; pointer-events: none;
        transform: translateY(8px);
      }
      #yteq-nav-btn {
        display: inline-flex; align-items: center; justify-content: center;
        padding: 4px 8px; border-radius: 4px; cursor: pointer;
        font-size: 12px; font-weight: 700; letter-spacing: 0.05em;
        color: #aaaacc; border: 1px solid transparent;
        transition: all 0.15s; background: transparent;
        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: #5555bb; background: #1e1e3a; }
      #yteq-header {
        display: flex; align-items: center; gap: 8px; padding: 8px 12px;
        border-bottom: 1px solid #2a2a3a; cursor: move;
      }
      #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-toggle-btn:hover { opacity: 0.8; }
      #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; margin-bottom: 2px; }
      .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;
      }
      .yteq-preset-btn:hover { background: #22223a; color: #9a9acc; border-color: #4a4a6a; }
      .yteq-preset-btn.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-label { font-size: 9px; color: #4a4a6a; letter-spacing: 0.08em; }
      #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;
      }
      #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-band-val { font-size: 7px; color: #6a6a8a; min-width: 20px; text-align: center; }
      .yteq-band-val.pos { color: #7878cc; }
      .yteq-band-val.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; transition: background 0.1s;
      }
      .yteq-band-slider::-webkit-slider-thumb:hover { background: #7777ff; }
      .yteq-band-label { 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-label { white-space: nowrap; }
      #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-label { font-size: 9px; color: #4a4a6a; letter-spacing: 0.08em; }
      #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-btn {
        flex: 1; background: #1a1a2a; border: 1px solid #2a2a3a; color: #6a6a9a;
        border-radius: 4px; padding: 4px 6px; cursor: pointer;
        font-size: 9px; font-family: inherit; font-weight: 700;
        text-align: left; transition: all 0.12s; overflow: hidden;
        text-overflow: ellipsis; white-space: nowrap;
      }
      .yteq-custom-btn:hover { background: #22223a; color: #9a9acc; border-color: #4a4a6a; }
      .yteq-custom-btn.active { background: #1e1e3a; border-color: #5555bb; color: #9999ff; }
      .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(style);

    const panel = document.createElement('div');
    panel.id = PANEL_ID;

    const groupsHTML = PRESET_GROUPS.map(group => {
      const btns = group.keys.map(key =>
        `<button class="yteq-preset-btn${key === lastSelectedPreset ? ' active' : ''}" data-preset="${key}">${PRESET_LABELS[key]}</button>`
      ).join('');
      return `<div>
        ${group.label ? `<div class="yteq-group-label">${group.label}</div>` : ''}
        <div class="yteq-preset-grid">${btns}</div>
      </div>`;
    }).join('');

    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-band-val ${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-band-label">${label}</span>
      </div>`;
    }).join('');

    panel.innerHTML = `
      <div id="yteq-header">
        <span id="yteq-title">YTyping EQ</span>
        <span id="yteq-status">⚪ Disconnected</span>
        <button id="yteq-toggle-btn">ON</button>
      </div>
      <div id="yteq-body">
        ${groupsHTML}
        <div id="yteq-eq-section">
          <div id="yteq-eq-header">
            <span id="yteq-eq-label">BANDS</span>
            <button id="yteq-eq-reset">RESET</button>
          </div>
          <div id="yteq-eq-sliders">${slidersHTML}</div>
          <div id="yteq-volume-row">
            <span id="yteq-volume-label">🔊 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 id="yteq-custom-label">CUSTOM</span>
            <button id="yteq-custom-save">+ Save current</button>
          </div>
          <div id="yteq-custom-list"></div>
        </div>
      </div>
    `;

    makeDraggable(panel, panel.querySelector('#yteq-header'));
    document.body.appendChild(panel);
    if (!panelVisible) panel.classList.add('hidden');

    // カスタムプリセット保存
    panel.querySelector('#yteq-custom-save').addEventListener('click', () => {
      const name = prompt('Enter preset name:');
      if (!name || !name.trim()) return;
      customPresets.push({ name: name.trim(), gains: [...currentGains] });
      saveCustomPresets();
      renderCustomPresets();
    });

    renderCustomPresets();

    panel.querySelectorAll('.yteq-preset-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const preset = btn.dataset.preset;
        currentPreset = preset;
        lastSelectedPreset = preset;
        currentGains = [...PRESETS[preset]];
        sendToEmbed({ type: 'SET_PRESET', preset });
        updatePresetButtons();
        updateSliders();
        updateResetBtn();
        saveSettings();
      });
    });

    BAND_LABELS.forEach((_, i) => {
      const slider = panel.querySelector(`#yteq-slider-${i}`);
      const valEl = panel.querySelector(`#yteq-val-${i}`);
      slider.addEventListener('input', () => {
        const g = parseFloat(slider.value);
        currentGains[i] = g;
        const sign = g > 0 ? '+' : '';
        valEl.textContent = `${sign}${g}`;
        valEl.className = `yteq-band-val ${g > 0 ? 'pos' : g < 0 ? 'neg' : ''}`;
        updateResetBtn();
        sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g });
        saveSettings();
      });
    });

    panel.querySelector('#yteq-eq-reset').addEventListener('click', () => {
      // lastSelectedPresetがカスタムか組み込みかで分岐
      if (lastSelectedPreset.startsWith('custom:')) {
        const idx = parseInt(lastSelectedPreset.split(':')[1]);
        const p = customPresets[idx];
        if (p) { currentGains = [...p.gains]; }
      } else if (PRESETS[lastSelectedPreset]) {
        currentPreset = lastSelectedPreset;
        currentGains = [...PRESETS[lastSelectedPreset]];
        sendToEmbed({ type: 'SET_PRESET', preset: lastSelectedPreset });
      }
      updateSliders();
      updatePresetButtons();
      updateResetBtn();
      saveSettings();
    });

    const volSlider = panel.querySelector('#yteq-volume-slider');
    const volVal = panel.querySelector('#yteq-volume-val');
    volSlider.addEventListener('input', () => {
      const pct = parseInt(volSlider.value);
      currentVolume = pct;
      volVal.textContent = `${pct}%`;
      sendToEmbed({ type: 'SET_VOLUME', value: pct / 100 });
      saveSettings();
    });

    panel.querySelector('#yteq-toggle-btn').addEventListener('click', () => {
      eqEnabled = !eqEnabled;
      sendToEmbed({ type: 'TOGGLE' });
      updateToggleBtn();
      updateNavBtn();
      saveSettings();
    });

    if (eqConnected) updateStatus(true);
    updateToggleBtn();
    updateResetBtn();
  }

  function renderCustomPresets() {
    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-custom-btn${JSON.stringify(p.gains) === JSON.stringify(currentGains) ? ' active' : ''}"
          data-idx="${idx}">${p.name}</button>
        <button class="yteq-custom-del" data-idx="${idx}">✕</button>
      </div>
    `).join('');

    list.querySelectorAll('.yteq-custom-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        const idx = parseInt(btn.dataset.idx);
        const p = customPresets[idx];
        if (!p) return;
        currentGains = [...p.gains];
        currentPreset = '';
        lastSelectedPreset = `custom:${idx}`;
        updatePresetButtons();
        updateSliders();
        updateResetBtn();
        renderCustomPresets();
        p.gains.forEach((g, i) => sendToEmbed({ type: 'SET_GAIN', bandIndex: i, gain: g }));
        saveSettings();
      });
    });

    list.querySelectorAll('.yteq-custom-del').forEach(btn => {
      btn.addEventListener('click', () => {
        const idx = parseInt(btn.dataset.idx);
        customPresets.splice(idx, 1);
        saveCustomPresets();
        renderCustomPresets();
      });
    });
  }

  function updateSliders() {
    BAND_LABELS.forEach((_, i) => {
      const slider = document.querySelector(`#yteq-slider-${i}`);
      const valEl = document.querySelector(`#yteq-val-${i}`);
      if (!slider || !valEl) return;
      const g = currentGains[i];
      slider.value = g;
      const sign = g > 0 ? '+' : '';
      valEl.textContent = `${sign}${g}`;
      valEl.className = `yteq-band-val ${g > 0 ? 'pos' : g < 0 ? 'neg' : ''}`;
    });
  }

  function updatePresetButtons() {
    document.querySelectorAll('.yteq-preset-btn').forEach(btn => {
      btn.classList.toggle('active', btn.dataset.preset === lastSelectedPreset);
    });
    document.querySelectorAll('.yteq-custom-btn').forEach(btn => {
      const idx = parseInt(btn.dataset.idx);
      btn.classList.toggle('active', lastSelectedPreset === `custom:${idx}`);
    });
  }

  function updateResetBtn() {
    const btn = document.getElementById('yteq-eq-reset');
    if (!btn) return;
    // lastSelectedPresetの値と現在のgainsが一致するか確認
    let baseGains = null;
    if (lastSelectedPreset.startsWith('custom:')) {
      const idx = parseInt(lastSelectedPreset.split(':')[1]);
      baseGains = customPresets[idx]?.gains ?? null;
    } else if (PRESETS[lastSelectedPreset]) {
      baseGains = PRESETS[lastSelectedPreset];
    }
    const isModified = baseGains && !baseGains.every((v, i) => v === currentGains[i]);
    btn.classList.toggle('hidden', !isModified);
  }

  function updateStatus(ok) {
    const el = document.getElementById('yteq-status');
    if (!el) return;
    el.textContent = ok ? '🟢 Connected' : '⚪ Disconnected';
  }

  function updateToggleBtn() {
    const btn = document.getElementById('yteq-toggle-btn');
    if (!btn) return;
    btn.textContent = eqEnabled ? 'ON' : 'OFF';
    btn.classList.toggle('off', !eqEnabled);
  }

  function updateNavBtn() {
    const btn = document.getElementById(NAV_BTN_ID);
    if (!btn) return;
    btn.classList.toggle('eq-on', eqEnabled);
  }

  function makeDraggable(el, handle) {
    let ox = 0, oy = 0, mx = 0, my = 0;
    handle.addEventListener('mousedown', e => {
      e.preventDefault();
      mx = e.clientX; my = e.clientY;
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });
    function onMove(e) {
      ox = mx - e.clientX; oy = my - e.clientY;
      mx = e.clientX; my = e.clientY;
      el.style.top = `${el.offsetTop - oy}px`;
      el.style.left = `${el.offsetLeft - ox}px`;
      el.style.right = 'auto'; el.style.bottom = 'auto';
    }
    function onUp() {
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    }
  }

  function togglePanel() {
    panelVisible = !panelVisible;
    const panel = document.getElementById(PANEL_ID);
    const btn = document.getElementById(NAV_BTN_ID);
    if (panel) panel.classList.toggle('hidden', !panelVisible);
    if (btn) btn.classList.toggle('active', panelVisible);
  }

  function insertNavBtn() {
    if (document.getElementById(NAV_BTN_ID)) return;
    const navIcons = document.getElementById('right-nav-icons');
    if (!navIcons) return;
    const btn = document.createElement('button');
    btn.id = NAV_BTN_ID;
    btn.textContent = 'EQ';
    btn.classList.toggle('active', panelVisible);
    btn.addEventListener('click', togglePanel);
    navIcons.prepend(btn);
    updateNavBtn();
  }

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

  // DOMが準備できたらUI初期化
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => { createUI(); insertNavBtn(); watchDOM(); });
  } else {
    createUI();
    insertNavBtn();
    watchDOM();
  }

})();