YouTube → Coolhole Queue Buttons

Add CH/NH buttons on YouTube to queue videos to coolhole.org / new.coolhole.org. Works as guest or logged-in. Silent queue if a Coolhole tab is already open.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         YouTube → Coolhole Queue Buttons
// @namespace    coolhole-queue-buttons
// @version      2.6.7
// @description  Add CH/NH buttons on YouTube to queue videos to coolhole.org / new.coolhole.org. Works as guest or logged-in. Silent queue if a Coolhole tab is already open.
// @author       soapylerd
// @match        *://*.youtube.com/*
// @match        *://youtube.com/*
// @include      /^https?:\/\/([a-z0-9-]+\.)*youtube\.[a-z.]+(\/|$)/i
// @match        https://coolhole.org/*
// @match        https://new.coolhole.org/*
// @noframes
// @connect      www.youtube.com
// @connect      youtube.com
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @grant        GM_openInTab
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.openInTab
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  const gm = {
    setValue(key, value) {
      try {
        if (typeof GM_setValue === 'function') return GM_setValue(key, value);
        if (typeof GM?.setValue === 'function') return GM.setValue(key, value);
      } catch (e) {
        console.warn('[CoolholeQueue] setValue failed', e);
      }
    },
    getValue(key, fallback) {
      try {
        if (typeof GM_getValue === 'function') return GM_getValue(key, fallback);
        if (typeof GM?.getValue === 'function') return fallback;
      } catch (e) {
        console.warn('[CoolholeQueue] getValue failed', e);
      }
      return fallback;
    },
    onValueChange(key, callback) {
      try {
        if (typeof GM_addValueChangeListener === 'function') {
          return GM_addValueChangeListener(key, callback);
        }
      } catch (e) {
        console.warn('[CoolholeQueue] value change listener unavailable', e);
      }
      return null;
    },
    openInTab(url, options = { active: false, insert: true, setParent: true }) {
      try {
        if (typeof GM_openInTab === 'function') return GM_openInTab(url, options);
        if (typeof GM?.openInTab === 'function') return GM.openInTab(url, options);
      } catch (e) {
        console.warn('[CoolholeQueue] openInTab failed, using window.open', e);
      }
      try {
        window.open(url, '_blank', 'noopener,noreferrer');
      } catch {
        try {
          location.assign(url);
        } catch (e2) {
          console.error('[CoolholeQueue] could not open URL', e2);
        }
      }
    },
  };

  const SERVERS = Object.freeze([
    Object.freeze({ key: 'ch', name: 'Coolhole', host: 'coolhole.org', short: 'CH' }),
    Object.freeze({ key: 'ch2', name: 'New Coolhole', host: 'new.coolhole.org', short: 'NH' }),
  ]);
  const queueKey = (key) => `cq_queue_${key}`;
  const aliveKey = (key) => `cq_alive_${key}`;
  const ALIVE_MAX_MS = 15_000;
  const BAD_TITLES = /^(shared\s*link|youtube|video|null|undefined|\s*)$/i;
  const CARD_SELECTORS = [
    'ytd-rich-item-renderer',
    'ytd-rich-grid-media',
    'ytd-grid-video-renderer',
    'ytd-video-renderer',
    'ytd-compact-video-renderer',
    'ytd-playlist-video-renderer',
    'ytd-reel-item-renderer',
    'yt-lockup-view-model',
    'ytd-lockup-view-model',
    'ytm-rich-item-renderer',
    'ytm-video-with-context-renderer',
    'ytm-compact-video-renderer',
  ].join(',');

  const host = location.hostname;
  const isYouTube = /(?:^|\.)youtube\./i.test(host);

  try {
    if (isYouTube) initYouTubeSide();
    else if (SERVERS.some((s) => s.host === host)) initCoolholeSide();
  } catch (e) {
    console.error('[CoolholeQueue] fatal init error', e);
  }

  function injectStyles(css) {
    try {
      const el = document.createElement('style');
      el.textContent = css;
      (document.head ?? document.documentElement).append(el);
    } catch (e) {
      console.error('[CoolholeQueue] injectStyles failed', e);
    }
  }

  function cleanTitle(value) {
    if (value == null) return null;
    const text = String(value).replace(/\s+/g, ' ').trim();
    if (!text || BAD_TITLES.test(text)) return null;
    return text.replace(/\s*-\s*YouTube\s*$/i, '').trim() || null;
  }

  // ======================================================================
  // YOUTUBE
  // ======================================================================
  function initYouTubeSide() {
    injectStyles(`
      .cq-pill {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        z-index: 2147483000;
        align-items: stretch;
        border-radius: 999px;
        overflow: hidden;
        box-shadow: 0 2px 8px rgba(0,0,0,.4), inset 0 0 0 1px rgba(255,255,255,.08);
        font-family: Roboto, Arial, sans-serif;
        line-height: 1;
        user-select: none;
        white-space: nowrap;
        pointer-events: auto;
      }
      .cq-pill .cq-seg {
        cursor: pointer;
        border: 0;
        margin: 0;
        padding: 5px 9px;
        font-size: 10px;
        font-weight: 700;
        letter-spacing: 0.06em;
        color: #fff;
        transition: background .15s ease, filter .15s ease, transform .1s ease;
        min-width: 2.4em;
        text-align: center;
      }
      .cq-pill .cq-seg:active { transform: scale(0.97); }
      .cq-pill .cq-seg.cq-sent { filter: brightness(1.25); }
      .cq-pill .cq-seg-ch {
        background: #4a4a4a;
        border-right: 1px solid rgba(0,0,0,.25);
      }
      .cq-pill .cq-seg-ch2 {
        background: #606060;
      }
      .cq-pill .cq-seg-ch:hover {
        background: linear-gradient(180deg, #e53935, #b71c1c);
      }
      .cq-pill .cq-seg-ch2:hover {
        background: linear-gradient(180deg, #1184e8, #0a5fad);
      }
      #cq-diag {
        position: fixed;
        bottom: 16px;
        left: 16px;
        max-width: 360px;
        padding: 10px 12px;
        border-radius: 8px;
        background: #1e1e1e;
        color: #f5f5f5;
        font: 12px/1.4 system-ui, sans-serif;
        z-index: 2147483646;
        box-shadow: 0 4px 16px rgba(0,0,0,.45);
        border: 1px solid #c62828;
      }
      #cq-diag strong { color: #ef5350; }
      #cq-diag button {
        margin: 8px 6px 0 0;
        cursor: pointer;
        border: 0;
        border-radius: 4px;
        padding: 4px 10px;
        font-size: 11px;
        font-weight: 600;
      }
      #cq-diag .cq-diag-dismiss { background: #424242; color: #fff; }
      #cq-diag .cq-diag-retry { background: #1184e8; color: #fff; }
    `);

    let scanScheduled = false;
    let scanCount = 0;
    let diagShown = false;

    const scheduleScan = () => {
      if (scanScheduled) return;
      scanScheduled = true;
      setTimeout(() => {
        scanScheduled = false;
        scanCards();
        injectWatchPage();
        maybeShowDiagnostics();
      }, 250);
    };

    document.addEventListener('yt-navigate-finish', () => {
      diagShown = false;
      hidePillNow();
      setTimeout(() => {
        scanCards();
        injectWatchPage();
        maybeShowDiagnostics();
      }, 400);
    });

    setTimeout(() => {
      scanCards();
      injectWatchPage();
      maybeShowDiagnostics();
    }, 600);
    setTimeout(maybeShowDiagnostics, 4000);
    setTimeout(maybeShowDiagnostics, 8000);

    try {
      new MutationObserver(scheduleScan).observe(document.documentElement, {
        childList: true,
        subtree: true,
      });
    } catch (e) {
      console.error('[CoolholeQueue] MutationObserver failed', e);
      setInterval(() => {
        scanCards();
        injectWatchPage();
      }, 2000);
    }

    let lastHref = location.href;
    setInterval(() => {
      if (location.href !== lastHref) {
        lastHref = location.href;
        diagShown = false;
        hidePillNow();
        scheduleScan();
      }
      scanCards();
      injectWatchPage();
    }, 2000);

    function videoIdFromHref(href) {
      try {
        const url = new URL(href, location.href);
        if (url.hostname.includes('youtu.be')) {
          return url.pathname.slice(1).split('/')[0] || null;
        }
        if (url.pathname === '/watch') return url.searchParams.get('v');
        const shorts = url.pathname.match(/^\/shorts\/([^/?]+)/);
        if (shorts) return shorts[1];
        const embed = url.pathname.match(/^\/embed\/([^/?]+)/);
        if (embed) return embed[1];
      } catch {
        /* ignore */
      }
      return null;
    }

    function currentWatchVideoId() {
      if (location.pathname === '/watch') {
        return new URLSearchParams(location.search).get('v');
      }
      return location.pathname.match(/^\/shorts\/([^/?]+)/)?.[1] ?? null;
    }

    function videoIdFromCard(card) {
      const link =
        card.querySelector('a#thumbnail[href]') ??
        card.querySelector('a#video-title-link[href]') ??
        card.querySelector('a#video-title[href]') ??
        card.querySelector('a[href*="/watch"]') ??
        card.querySelector('a[href*="/shorts/"]');
      return link?.href ? videoIdFromHref(link.href) : null;
    }

    function titleFromCard(card) {
      const candidates = [];
      const titleEl =
        card.querySelector('#video-title') ??
        card.querySelector('a#video-title-link') ??
        card.querySelector('[id="video-title"]') ??
        card.querySelector('yt-formatted-string#video-title') ??
        card.querySelector('h3 a') ??
        card.querySelector('a[title]');
      if (titleEl) {
        candidates.push(titleEl.getAttribute('title'), titleEl.textContent);
      }
      const thumb = card.querySelector('a#thumbnail, a[href*="/watch"], a[href*="/shorts/"]');
      const aria = thumb?.getAttribute('aria-label');
      if (aria) candidates.push(aria.replace(/\s+by\s+.+$/i, '').trim());
      for (const candidate of candidates) {
        const cleaned = cleanTitle(candidate);
        if (cleaned) return cleaned;
      }
      return null;
    }

    function titleFromWatchPage() {
      const el =
        document.querySelector('h1.ytd-watch-metadata yt-formatted-string') ??
        document.querySelector('ytd-watch-metadata h1 yt-formatted-string') ??
        document.querySelector('h1 yt-formatted-string') ??
        document.querySelector('#title h1') ??
        document.querySelector('yt-shorts-video-title-view-model h2') ??
        document.querySelector('h2.ytd-reel-player-header-renderer');
      if (el?.textContent) return cleanTitle(el.textContent);
      return cleanTitle(document.title?.replace(/\s*-\s*YouTube\s*$/i, ''));
    }

    function findMenu(card) {
      return (
        card.querySelector('ytd-menu-renderer') ??
        card.querySelector('#menu') ??
        card.querySelector('button[aria-label="More actions"]') ??
        card.querySelector('button[aria-label="Action menu"]') ??
        card.querySelector('button[aria-label*="More"]')
      );
    }

    function findWatchMenu() {
      return (
        document.querySelector('#actions ytd-menu-renderer') ??
        document.querySelector('#actions-inner ytd-menu-renderer') ??
        document.querySelector('ytd-watch-metadata ytd-menu-renderer') ??
        document.querySelector('#menu-container ytd-menu-renderer') ??
        document.querySelector('ytd-menu-renderer.ytd-watch-metadata') ??
        document.querySelector('#actions button[aria-label="More actions"]') ??
        document.querySelector('#actions button[aria-label*="More"]') ??
        document.querySelector('ytd-reel-player-overlay-renderer ytd-menu-renderer') ??
        document.querySelector('#actions.ytd-reel-player-overlay-renderer ytd-menu-renderer') ??
        document.querySelector('ytd-shorts ytd-menu-renderer')
      );
    }

    function isServerAlive(server) {
      const ts = gm.getValue(aliveKey(server.key), 0);
      return typeof ts === 'number' && Date.now() - ts < ALIVE_MAX_MS;
    }

    function flash(btn) {
      btn?.classList.add('cq-sent');
      setTimeout(() => btn?.classList.remove('cq-sent'), 900);
    }

    function sendToServer(server, videoId, title, btn) {
      if (!videoId) {
        console.warn('[CoolholeQueue] missing videoId');
        return;
      }
      const payload = {
        videoId: String(videoId),
        title: title ?? null,
        n: Date.now(),
      };
      try {
        if (isServerAlive(server)) {
          gm.setValue(queueKey(server.key), payload);
          flash(btn);
          console.log(
            `%c[CoolholeQueue]%c → ${server.name}: ${title ?? videoId}`,
            'background:#1184e8;color:#fff;font-weight:600;padding:1px 6px;border-radius:3px;',
            'color:inherit;'
          );
          return;
        }
        const hash =
          `cq_add=${encodeURIComponent(videoId)}` +
          (title ? `&cq_title=${encodeURIComponent(title)}` : '') +
          `&n=${payload.n}`;
        gm.openInTab(`https://${server.host}/#${hash}`);
        gm.setValue(queueKey(server.key), payload);
        flash(btn);
        console.log(
          `%c[CoolholeQueue]%c opened ${server.name}: ${title ?? videoId}`,
          'background:#1184e8;color:#fff;font-weight:600;padding:1px 6px;border-radius:3px;',
          'color:inherit;'
        );
      } catch (e) {
        console.error('[CoolholeQueue] send failed', e);
        alert('CoolholeQueue error: could not send video. See the browser console for details.');
      }
    }

    const currentCtx = { id: null, title: null };
    let hideTimer = null;

    function clearHideTimer() {
      if (hideTimer) {
        clearTimeout(hideTimer);
        hideTimer = null;
      }
    }

    function scheduleHide() {
      clearHideTimer();
      hideTimer = setTimeout(() => {
        sharedPill.style.display = 'none';
      }, 250);
    }

    function hidePillNow() {
      clearHideTimer();
      sharedPill.style.display = 'none';
    }

    function buildSharedPill() {
      const pill = document.createElement('div');
      pill.className = 'cq-pill';
      pill.setAttribute('role', 'group');
      pill.title = 'Queue to Coolhole';
      for (const server of SERVERS) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = `cq-seg cq-seg-${server.key}`;
        btn.textContent = server.short;
        btn.title = `Send to ${server.name} (end of queue)`;
        btn.addEventListener('click', (event) => {
          event.preventDefault();
          event.stopPropagation();
          event.stopImmediatePropagation();
          if (!currentCtx.id) {
            alert('Could not detect a video ID for this item.');
            return;
          }
          sendToServer(server, currentCtx.id, currentCtx.title, btn);
        });
        pill.append(btn);
      }
      pill.addEventListener('mouseenter', clearHideTimer);
      pill.addEventListener('mouseleave', scheduleHide);
      const mount = () => {
        (document.body ?? document.documentElement).append(pill);
      };
      if (document.body) mount();
      else document.addEventListener('DOMContentLoaded', mount, { once: true });
      return pill;
    }

    const sharedPill = buildSharedPill();

    function positionPill(anchorEl, preferAbove) {
      try {
        const rect = anchorEl.getBoundingClientRect();
        const pillRect = sharedPill.getBoundingClientRect();
        const margin = 6;
        const spaceAbove = rect.top;
        const spaceBelow = window.innerHeight - rect.bottom;
        const placeAbove = preferAbove
          ? spaceAbove >= pillRect.height + margin || spaceAbove >= spaceBelow
          : spaceBelow < pillRect.height + margin && spaceAbove > spaceBelow;
        const top = placeAbove ? rect.top - pillRect.height - margin : rect.bottom + margin;
        let left = rect.right - pillRect.width;
        if (left < 4) left = 4;
        if (left + pillRect.width > window.innerWidth - 4) {
          left = window.innerWidth - pillRect.width - 4;
        }
        sharedPill.style.top = `${Math.max(4, top)}px`;
        sharedPill.style.left = `${left}px`;
      } catch (e) {
        console.warn('[CoolholeQueue] positionPill failed', e);
      }
    }

    function showPillFor(anchorEl, ctx, preferAbove) {
      if (!ctx || !ctx.id || !anchorEl) return;
      currentCtx.id = ctx.id;
      currentCtx.title = ctx.title;
      clearHideTimer();
      sharedPill.style.visibility = 'hidden';
      sharedPill.style.display = 'inline-flex';
      positionPill(anchorEl, preferAbove);
      sharedPill.style.visibility = 'visible';
    }

    window.addEventListener('scroll', () => hidePillNow(), true);
    window.addEventListener('resize', () => hidePillNow());

    function attachHoverTrigger(triggerEl, getContext, preferAbove, positionEl) {
      if (!triggerEl || triggerEl.dataset.cqHooked === '1') return;
      triggerEl.dataset.cqHooked = '1';
      const anchor = positionEl || triggerEl;
      triggerEl.addEventListener('mouseenter', () => {
        let ctx = null;
        try {
          ctx = getContext();
        } catch (e) {
          console.warn('[CoolholeQueue] getContext failed', e);
        }
        if (ctx && ctx.id) showPillFor(anchor, ctx, preferAbove);
      });
      triggerEl.addEventListener('mouseleave', scheduleHide);
    }

    function injectUnderMenu(card) {
      if (card.dataset.cqHooked === '1') return;
      const menu = findMenu(card);
      if (!menu || !videoIdFromCard(card)) return;
      attachHoverTrigger(
        card,
        () => ({ id: videoIdFromCard(card), title: titleFromCard(card) }),
        false,
        menu
      );
    }

    function injectWatchPage() {
      const id = currentWatchVideoId();
      if (!id) return;
      const menu = findWatchMenu();
      if (!menu) return;
      attachHoverTrigger(
        menu,
        () => ({ id: currentWatchVideoId(), title: titleFromWatchPage() }),
        true
      );
    }

    function scanCards() {
      scanCount += 1;
      let touched = 0;
      for (const card of document.querySelectorAll(CARD_SELECTORS)) {
        try {
          injectUnderMenu(card);
          if (card.dataset.cqHooked === '1') touched += 1;
        } catch (e) {
          console.warn('[CoolholeQueue] card inject error', e);
        }
      }
      const total = document.querySelectorAll('[data-cq-hooked="1"]').length;
      if (scanCount <= 3 || scanCount % 10 === 0) {
        console.log(`[CoolholeQueue] scan #${scanCount} — hover triggers: ${total} (cards: ${touched})`);
      }
    }

    function shouldExpectPills() {
      const path = location.pathname ?? '';
      if (
        path === '/' ||
        path.startsWith('/feed') ||
        path.startsWith('/results') ||
        path.startsWith('/watch') ||
        path.startsWith('/shorts') ||
        path.startsWith('/channel') ||
        path.startsWith('/@') ||
        path.startsWith('/playlist')
      ) {
        return true;
      }
      return document.querySelector(CARD_SELECTORS) != null;
    }

    function collectDiagReasons() {
      const reasons = [];
      const cards = document.querySelectorAll(CARD_SELECTORS);
      const menus = document.querySelectorAll(
        'ytd-menu-renderer, #menu, button[aria-label="More actions"], button[aria-label*="More"]'
      );
      const hooks = document.querySelectorAll('[data-cq-hooked="1"]');
      if (!document.body) reasons.push('Page body not ready yet.');
      if (cards.length === 0) {
        reasons.push('No video cards found (layout change, or page has no videos yet).');
      } else {
        reasons.push(`Found ${cards.length} video card(s).`);
      }
      if (menus.length === 0) {
        reasons.push('No ⋮ menus found — pills position against the menu.');
      } else {
        reasons.push(`Found ${menus.length} menu control(s).`);
      }
      if (hooks.length === 0) {
        reasons.push('Zero hover triggers attached after scans.');
      } else {
        reasons.push(`${hooks.length} hover trigger(s) attached (hover a card / ⋮ to reveal).`);
      }
      if (typeof GM_setValue !== 'function' && typeof GM?.setValue !== 'function') {
        reasons.push(
          'Userscript storage APIs missing — install Tampermonkey or Violentmonkey and allow this script’s grants.'
        );
      }
      return reasons;
    }

    function maybeShowDiagnostics() {
      if (diagShown || !shouldExpectPills()) return;
      if (document.querySelectorAll('[data-cq-hooked="1"]').length > 0) return;
      if (scanCount < 2) return;
      diagShown = true;
      const reasons = collectDiagReasons();
      console.warn('[CoolholeQueue] Diagnostics — no pills visible:', reasons);
      document.getElementById('cq-diag')?.remove();
      const box = document.createElement('div');
      box.id = 'cq-diag';
      box.innerHTML =
        '<strong>CoolholeQueue:</strong> buttons did not appear.<br>' +
        reasons.map((r) => `• ${r}`).join('<br>') +
        '<br><button type="button" class="cq-diag-retry">Retry scan</button>' +
        '<button type="button" class="cq-diag-dismiss">Dismiss</button>';
      (document.body ?? document.documentElement).append(box);
      box.querySelector('.cq-diag-dismiss')?.addEventListener('click', () => box.remove());
      box.querySelector('.cq-diag-retry')?.addEventListener('click', () => {
        box.remove();
        diagShown = false;
        scanCount = 0;
        scanCards();
        injectWatchPage();
        setTimeout(maybeShowDiagnostics, 1500);
      });
    }
  }

  // ======================================================================
  // COOLHOLE
  // ======================================================================
  function initCoolholeSide() {
    const server = SERVERS.find((s) => s.host === host);
    if (!server) return;

    const SUCCESS_LINES = [
      'Queued: {title}',
      'Dropped "{title}" at the end of the line',
      'Into the hole: {title}',
      'Locked and loaded: {title}',
      'Added to the pile: {title}',
      'Queue fed. "{title}" is in.',
      'Yoink — "{title}" is yours now',
      'Slid "{title}" onto the runway',
      'Fresh meat for the playlist: {title}',
      'The hole accepts "{title}"',
      'Stashed "{title}" for later',
      'One more for the road: {title}',
      'Sealed the deal: {title}',
      'Coolhole ate "{title}"',
      'Parked "{title}" at the back',
    ];
    const FAIL_LINES = [
      'Could not auto-queue — open the Add panel and try again.',
      'Queue miss. Open Library / Add and retry.',
      'The hole spat it back out. Try the Add panel.',
      'Auto-queue failed. Manual Add is your friend.',
    ];
    const MAX_QUEUE_MSG =
      'Max items already in the hole — wait for your video to finish, then try again.';

    const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
    const formatLine = (template, title) =>
      template.replaceAll('{title}', title || 'Unknown video');

    function getRecentErrorText() {
      const chunks = [];
      const selectors = [
        '#messagebuffer .server-whisper',
        '#messagebuffer .server-msg',
        '#messagebuffer .action',
        '#messagebuffer .chat-msg-server',
        '.alert-danger',
        '.alert-warning',
        '#qfail',
        '#queuefail',
        '.queue-error',
      ];
      for (const sel of selectors) {
        try {
          document.querySelectorAll(sel).forEach((el) => {
            const t = el.textContent?.trim();
            if (t) chunks.push(t);
          });
        } catch {
          /* ignore */
        }
      }
      try {
        const buf = document.querySelector('#messagebuffer');
        if (buf) {
          for (const n of Array.from(buf.querySelectorAll('div, span, p, li')).slice(-15)) {
            const t = n.textContent?.trim();
            if (t) chunks.push(t);
          }
        }
      } catch {
        /* ignore */
      }
      return chunks.join('\n');
    }

    // Matches: "You already have 3 items queued (limit 3). Wait for one to play."
    function isMaxQueueError(text) {
      if (!text) return false;
      if (/already have \d+\s+items?\s+queued/i.test(text)) return true;
      if (/wait for one to play/i.test(text)) return true;
      if (/limit\s*\d+/i.test(text) && /queued/i.test(text)) return true;
      if (/queue(?:d)?\s*(?:is\s*)?(?:full|limit)/i.test(text)) return true;
      if (/too many (videos?|items?)/i.test(text)) return true;
      return false;
    }

    function getSessionInfo() {
      try {
        if (typeof CLIENT !== 'undefined' && CLIENT && typeof CLIENT === 'object') {
          const name = String(CLIENT.name ?? '').trim();
          const rank = typeof CLIENT.rank === 'number' ? CLIENT.rank : null;
          const nameLooksGuest = !name || /^guest/i.test(name) || /^anon/i.test(name);
          let isGuest;
          if (rank === 0) isGuest = true;
          else if (typeof rank === 'number' && rank >= 1) isGuest = false;
          else if (name && !nameLooksGuest) isGuest = false;
          else isGuest = nameLooksGuest;
          const connected = rank === null ? Boolean(name) : rank >= 0;
          return { name: name || 'user', rank, isGuest, connected };
        }
      } catch {
        /* ignore */
      }
      const guestForm =
        document.querySelector('#guestname') ||
        document.querySelector('input[name="guestname"]') ||
        document.querySelector('#guestlogin');
      if (guestForm && guestForm.offsetParent !== null) {
        return { name: 'guest', rank: 0, isGuest: true, connected: false };
      }
      const nameEl =
        document.querySelector('#welcome .username') ||
        document.querySelector('.user-dropdown .username') ||
        document.querySelector('#chatheader .username') ||
        document.querySelector('[data-username]');
      const name =
        nameEl?.textContent?.trim() ||
        nameEl?.getAttribute?.('data-username') ||
        '';
      if (name && !/^guest/i.test(name) && !/^anon/i.test(name)) {
        return { name, rank: 1, isGuest: false, connected: true };
      }
      return { name: name || '', rank: null, isGuest: false, connected: Boolean(name) };
    }

    function fancyLog(label, ok, gold, maxed) {
      const style = ok
        ? gold
          ? 'background:linear-gradient(90deg,#f9a825,#ffd54f,#f9a825);color:#1a1200;font-weight:700;padding:2px 8px;border-radius:4px;'
          : 'background:#1184e8;color:#fff;font-weight:600;padding:2px 8px;border-radius:4px;'
        : maxed
          ? 'background:#ef6c00;color:#fff;font-weight:600;padding:2px 8px;border-radius:4px;'
          : 'background:#c62828;color:#ffebee;font-weight:600;padding:2px 8px;border-radius:4px;';
      const tag = ok ? (gold ? '★ GOLD QUEUE' : 'QUEUE') : maxed ? 'MAX QUEUE' : 'FAIL';
      console.log(`%c[CoolholeQueue] ${tag}%c ${label ?? ''}`, style, 'color:inherit;font-weight:500;');
    }

    injectStyles(`
      #cq-toast {
        position: fixed;
        bottom: 20px;
        right: 20px;
        padding: 10px 14px;
        border-radius: 6px;
        color: #fff;
        font: 600 13px/1.35 system-ui, sans-serif;
        z-index: 999999;
        opacity: 0;
        transform: translateY(10px) scale(0.98);
        transition: opacity .25s ease, transform .25s ease;
        pointer-events: none;
        max-width: 380px;
        box-shadow: 0 2px 8px rgba(0,0,0,.22);
      }
      #cq-toast.show { opacity: 1; transform: translateY(0) scale(1); }
      #cq-toast.cq-fail { animation: cq-shake .45s ease; }
      #cq-toast.cq-max {
        background: linear-gradient(180deg, #fb8c00, #ef6c00) !important;
      }
      #cq-toast.cq-gold {
        color: #1a1200;
        background: linear-gradient(135deg, #f9a825, #ffd54f 40%, #ffb300 70%, #f9a825);
        background-size: 200% 200%;
        animation: cq-gold-shine 1.6s ease-in-out infinite;
        box-shadow: 0 2px 10px rgba(249,168,37,.35);
      }
      @keyframes cq-gold-shine {
        0% { background-position: 0% 50%; }
        50% { background-position: 100% 50%; }
        100% { background-position: 0% 50%; }
      }
      @keyframes cq-shake {
        0%, 100% { transform: translateY(0) translateX(0); }
        20% { transform: translateY(0) translateX(-6px); }
        40% { transform: translateY(0) translateX(6px); }
        60% { transform: translateY(0) translateX(-4px); }
        80% { transform: translateY(0) translateX(4px); }
      }
      @media (prefers-reduced-motion: reduce) {
        #cq-toast, #cq-toast.cq-fail, #cq-toast.cq-gold {
          animation: none !important;
          transition: opacity .15s ease !important;
        }
      }
    `);

    // Toast = message only. No GUEST. No extra nodes.
    function showToast(msg, ok, gold, maxed) {
      let toast = document.getElementById('cq-toast');
      if (!toast) {
        toast = document.createElement('div');
        toast.id = 'cq-toast';
        (document.body ?? document.documentElement).append(toast);
      }
      toast.textContent = msg;
      toast.classList.remove('show', 'cq-fail', 'cq-gold', 'cq-max');
      if (maxed) {
        toast.style.background = '';
        toast.classList.add('cq-max');
      } else if (!ok) {
        toast.style.background = 'linear-gradient(180deg, #e53935, #b71c1c)';
        toast.classList.add('cq-fail');
      } else if (gold) {
        toast.style.background = '';
        toast.classList.add('cq-gold');
      } else {
        toast.style.background = 'linear-gradient(180deg, #1184e8, #0a5fad)';
      }
      void toast.offsetWidth;
      toast.classList.add('show');
      clearTimeout(toast._cqTimer);
      toast._cqTimer = setTimeout(() => {
        toast.classList.remove('show', 'cq-fail', 'cq-gold', 'cq-max');
      }, maxed ? 5200 : gold ? 4200 : 3600);
    }

    const beat = () => gm.setValue(aliveKey(server.key), Date.now());
    beat();
    setInterval(beat, 5000);
    window.addEventListener('beforeunload', () => gm.setValue(aliveKey(server.key), 0));

    const listenerOk = gm.onValueChange(queueKey(server.key), (_name, _old, next) => {
      if (!next?.videoId) return;
      queueVideo(String(next.videoId), cleanTitle(next.title));
    });
    if (!listenerOk && typeof GM_addValueChangeListener !== 'function') {
      console.warn(
        '[CoolholeQueue] No storage listener — install Tampermonkey or Violentmonkey for silent cross-tab queue.'
      );
    }

    window.addEventListener('hashchange', processHash);
    window.addEventListener('load', () => setTimeout(processHash, 600));
    if (document.readyState !== 'loading') setTimeout(processHash, 600);

    function parseHashRequest() {
      const add = location.hash.match(/cq_add=([^&]+)/);
      if (!add) return null;
      const titleMatch = location.hash.match(/cq_title=([^&]+)/);
      return {
        videoId: decodeURIComponent(add[1]),
        title: titleMatch ? cleanTitle(decodeURIComponent(titleMatch[1])) : null,
      };
    }

    function clearHash() {
      try {
        history.replaceState(null, '', `${location.pathname}${location.search}`);
      } catch {
        location.hash = '';
      }
    }

    async function fetchTitle(videoId) {
      try {
        const oembed = new URL('https://www.youtube.com/oembed');
        oembed.searchParams.set('url', `https://www.youtube.com/watch?v=${videoId}`);
        oembed.searchParams.set('format', 'json');
        const res = await fetch(oembed);
        if (!res.ok) return null;
        const data = await res.json();
        return cleanTitle(data?.title);
      } catch {
        return null;
      }
    }

    function waitFor(selector, timeoutMs = 12_000) {
      return new Promise((resolve, reject) => {
        const existing = document.querySelector(selector);
        if (existing) {
          resolve(existing);
          return;
        }
        const observer = new MutationObserver(() => {
          const found = document.querySelector(selector);
          if (found) {
            observer.disconnect();
            clearTimeout(timer);
            resolve(found);
          }
        });
        observer.observe(document.documentElement, { childList: true, subtree: true });
        const timer = setTimeout(() => {
          observer.disconnect();
          reject(new Error(`timed out waiting for ${selector}`));
        }, timeoutMs);
      });
    }

    function getSelectors() {
      if (document.querySelector('#queue-url') || host === 'new.coolhole.org') {
        return { mediaUrl: '#queue-url', queueEnd: '#btn-queue', expandBtn: null, expandTarget: null };
      }
      return {
        mediaUrl: '#mediaurl',
        queueEnd: '#queue_end',
        expandBtn: '#showmediaurl',
        expandTarget: '#addfromurl',
      };
    }

    async function ensurePanelOpen(sel) {
      if (!sel.expandTarget) return;
      const panel = document.querySelector(sel.expandTarget);
      if (panel?.classList.contains('in') || panel?.classList.contains('show')) return;
      if (sel.expandBtn) {
        document.querySelector(sel.expandBtn)?.click();
        await new Promise((r) => setTimeout(r, 250));
      }
      if (panel) {
        panel.classList.add('in', 'show');
        panel.style.display = 'block';
        panel.style.height = 'auto';
      }
    }

    function setNativeInputValue(input, value) {
      const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
      if (descriptor?.set) descriptor.set.call(input, value);
      else input.value = value;
      input.dispatchEvent(new Event('input', { bubbles: true }));
      input.dispatchEvent(new Event('change', { bubbles: true }));
    }

    async function tryGuestConnect() {
      try {
        const session = getSessionInfo();
        if (session.connected && session.name && !session.isGuest) return session;
        const nameInput =
          document.querySelector('#guestname') ||
          document.querySelector('input[name="guestname"]');
        const joinBtn =
          document.querySelector('#guestlogin') ||
          document.querySelector('button[type="submit"]') ||
          document.querySelector('#guestjoin');
        if (nameInput && (!nameInput.value || !String(nameInput.value).trim())) {
          nameInput.value = `Guest${Math.floor(1000 + Math.random() * 9000)}`;
          nameInput.dispatchEvent(new Event('input', { bubbles: true }));
        }
        if (joinBtn && nameInput) {
          joinBtn.click();
          await new Promise((r) => setTimeout(r, 500));
        }
      } catch (e) {
        console.warn('[CoolholeQueue] guest connect helper failed', e);
      }
      return getSessionInfo();
    }

    let lastQueued = { id: null, at: 0 };

    async function queueVideo(videoId, title) {
      if (!videoId) return;
      const now = Date.now();
      if (lastQueued.id === videoId && now - lastQueued.at < 2000) return;
      lastQueued = { id: videoId, at: now };

      const displayTitle = cleanTitle(title) ?? (await fetchTitle(videoId)) ?? videoId;
      const sel = getSelectors();

      let session = getSessionInfo();
      if (session.isGuest && (!session.connected || document.querySelector('#guestname'))) {
        await tryGuestConnect();
      }

      try {
        await ensurePanelOpen(sel);
        const input = await waitFor(sel.mediaUrl);
        const queueEndBtn = await waitFor(sel.queueEnd);

        if (queueEndBtn.disabled) {
          await new Promise((r) => setTimeout(r, 250));
          if (isMaxQueueError(getRecentErrorText())) {
            showToast(MAX_QUEUE_MSG, false, false, true);
            fancyLog(displayTitle, false, false, true);
            return;
          }
          throw new Error('Queue button is disabled');
        }

        const before = getRecentErrorText();
        setNativeInputValue(input, `https://www.youtube.com/watch?v=${videoId}`);
        await new Promise((r) => setTimeout(r, 80));
        queueEndBtn.click();

        // Wait for room response, then choose toast
        await new Promise((r) => setTimeout(r, 600));
        const after = getRecentErrorText();
        const fresh = after.length > before.length ? after.slice(before.length) : after;

        if (isMaxQueueError(fresh) || isMaxQueueError(after)) {
          showToast(MAX_QUEUE_MSG, false, false, true);
          fancyLog(displayTitle, false, false, true);
          return;
        }

        // Success: quirky line + title only — no GUEST
        const gold = Math.random() < 0.12;
        showToast(formatLine(pick(SUCCESS_LINES), displayTitle), true, gold, false);
        fancyLog(displayTitle, true, gold, false);
      } catch (err) {
        console.error('[CoolholeQueue] failed:', err);
        const errText = String(err?.message || '') + '\n' + getRecentErrorText();
        if (isMaxQueueError(errText)) {
          showToast(MAX_QUEUE_MSG, false, false, true);
          fancyLog(displayTitle, false, false, true);
        } else {
          showToast(pick(FAIL_LINES), false, false, false);
          fancyLog(displayTitle, false, false, false);
        }
      }
    }

    function processHash() {
      const req = parseHashRequest();
      if (!req?.videoId) return;
      clearHash();
      queueVideo(req.videoId, req.title);
    }
  }
})();