YouTube → Coolhole Queue Buttons

CH queues to Coolhole. CP focuses the hole and clicks Work (Earn CP). All major YouTube domains.

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

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

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube → Coolhole Queue Buttons
// @namespace    coolhole-queue-buttons
// @version      2.6.8
// @description  CH queues to Coolhole. CP focuses the hole and clicks Work (Earn CP). All major YouTube domains.
// @author       soapylerd
// @match        *://*.youtube.com/*
// @match        *://youtube.com/*
// @match        *://m.youtube.com/*
// @match        *://www.youtube.com/*
// @match        *://*.youtube.co.uk/*
// @match        *://youtube.co.uk/*
// @match        *://*.youtube.ca/*
// @match        *://youtube.ca/*
// @match        *://*.youtube.de/*
// @match        *://youtube.de/*
// @match        *://*.youtube.fr/*
// @match        *://youtube.fr/*
// @match        *://*.youtube.ie/*
// @match        *://youtube.ie/*
// @match        *://*.youtube.nl/*
// @match        *://youtube.nl/*
// @match        *://*.youtube.es/*
// @match        *://youtube.es/*
// @match        *://*.youtube.it/*
// @match        *://youtube.it/*
// @match        *://*.youtube.pl/*
// @match        *://youtube.pl/*
// @match        *://*.youtube.com.au/*
// @match        *://youtube.com.au/*
// @match        *://*.youtube.co.jp/*
// @match        *://youtube.co.jp/*
// @match        *://*.youtube.in/*
// @match        *://youtube.in/*
// @match        *://*.youtube.com.br/*
// @match        *://youtube.com.br/*
// @match        *://*.youtube.ru/*
// @match        *://youtube.ru/*
// @match        *://*.youtube.com.mx/*
// @match        *://youtube.com.mx/*
// @match        *://*.youtube.se/*
// @match        *://youtube.se/*
// @match        *://*.youtube.no/*
// @match        *://youtube.no/*
// @match        *://*.youtube.fi/*
// @match        *://youtube.fi/*
// @match        *://*.youtube.dk/*
// @match        *://youtube.dk/*
// @match        *://*.youtube.be/*
// @match        *://youtube.be/*
// @match        *://*.youtube.at/*
// @match        *://youtube.at/*
// @match        *://*.youtube.ch/*
// @match        *://youtube.ch/*
// @match        *://*.youtube.pt/*
// @match        *://youtube.pt/*
// @match        *://*.youtube.com.ar/*
// @match        *://youtube.com.ar/*
// @match        *://*.youtube.co.za/*
// @match        *://youtube.co.za/*
// @match        *://*.youtube.com.tr/*
// @match        *://youtube.com.tr/*
// @match        *://*.youtube.com.sg/*
// @match        *://youtube.com.sg/*
// @match        *://*.youtube.ph/*
// @match        *://youtube.ph/*
// @match        *://*.youtube.co.id/*
// @match        *://youtube.co.id/*
// @match        *://*.youtube.ae/*
// @match        *://youtube.ae/*
// @match        *://*.youtube.co.il/*
// @match        *://youtube.co.il/*
// @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: true, 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 HOSTS = Object.freeze(['coolhole.org', 'new.coolhole.org']);
  const PRIMARY_HOST = 'coolhole.org';
  const queueKey = 'cq_queue';
  const workKey = 'cq_work';
  const aliveKey = (h) => `cq_alive_${h}`;
  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);
  const isCoolhole = HOSTS.includes(host);

  try {
    if (isYouTube) initYouTubeSide();
    else if (isCoolhole) 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;
  }

  function anyHostAlive() {
    return HOSTS.some((h) => {
      const ts = gm.getValue(aliveKey(h), 0);
      return typeof ts === 'number' && Date.now() - ts < ALIVE_MAX_MS;
    });
  }

  // ======================================================================
  // 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-cp {
        background: #606060;
      }
      .cq-pill .cq-seg-ch:hover {
        background: linear-gradient(180deg, #e53935, #b71c1c);
      }
      .cq-pill .cq-seg-cp: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 flash(btn) {
      btn?.classList.add('cq-sent');
      setTimeout(() => btn?.classList.remove('cq-sent'), 900);
    }

    function sendQueue(videoId, title, btn) {
      if (!videoId) {
        console.warn('[CoolholeQueue] missing videoId');
        return;
      }
      const payload = {
        videoId: String(videoId),
        title: title ?? null,
        n: Date.now(),
      };
      try {
        if (anyHostAlive()) {
          gm.setValue(queueKey, payload);
          flash(btn);
          console.log(
            `%c[CoolholeQueue]%c → queue: ${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://${PRIMARY_HOST}/#${hash}`, { active: true, insert: true, setParent: true });
        gm.setValue(queueKey, payload);
        flash(btn);
      } catch (e) {
        console.error('[CoolholeQueue] send failed', e);
        alert('CoolholeQueue error: could not send video. See the browser console for details.');
      }
    }

    function sendWork(btn) {
      try {
        const payload = { n: Date.now() };
        if (anyHostAlive()) {
          gm.setValue(workKey, payload);
          flash(btn);
          console.log(
            `%c[CoolholeQueue]%c → Work`,
            'background:#1184e8;color:#fff;font-weight:600;padding:1px 6px;border-radius:3px;',
            'color:inherit;'
          );
          return;
        }
        gm.openInTab(`https://${PRIMARY_HOST}/#cq_work=1&n=${payload.n}`, {
          active: true,
          insert: true,
          setParent: true,
        });
        gm.setValue(workKey, payload);
        flash(btn);
      } catch (e) {
        console.error('[CoolholeQueue] work signal failed', e);
        alert('CoolholeQueue error: could not signal Work. See the browser console.');
      }
    }

    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 = 'Coolhole';

      const ch = document.createElement('button');
      ch.type = 'button';
      ch.className = 'cq-seg cq-seg-ch';
      ch.textContent = 'CH';
      ch.title = 'Queue to Coolhole';
      ch.addEventListener('click', (event) => {
        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation();
        if (!currentCtx.id) {
          alert('Could not detect a video ID for this item.');
          return;
        }
        sendQueue(currentCtx.id, currentCtx.title, ch);
      });

      const cp = document.createElement('button');
      cp.type = 'button';
      cp.className = 'cq-seg cq-seg-cp';
      cp.textContent = 'CP';
      cp.title = 'Focus Coolhole and Work (Earn CP)';
      cp.addEventListener('click', (event) => {
        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation();
        sendWork(cp);
      });

      pill.append(ch, cp);
      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.');
      else reasons.push(`Found ${cards.length} video card(s).`);
      if (menus.length === 0) reasons.push('No ⋮ menus found.');
      else reasons.push(`Found ${menus.length} menu control(s).`);
      if (hooks.length === 0) reasons.push('Zero hover triggers attached.');
      else reasons.push(`${hooks.length} hover trigger(s) attached.`);
      if (typeof GM_setValue !== 'function' && typeof GM?.setValue !== 'function') {
        reasons.push('Userscript storage APIs missing.');
      }
      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:', 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 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');
    }

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

    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(host), Date.now());
    beat();
    setInterval(beat, 5000);
    window.addEventListener('beforeunload', () => gm.setValue(aliveKey(host), 0));

    function getWorkButton() {
      return (
        document.querySelector('#job-actions button.job-action-work') ||
        document.querySelector('button.job-action-work') ||
        document.querySelector('button[title="Earn CP"]')
      );
    }

    async function doWorkAction() {
      try {
        window.focus();
      } catch {
        /* ignore */
      }

      let btn = getWorkButton();
      if (!btn) {
        for (let i = 0; i < 20 && !btn; i++) {
          await new Promise((r) => setTimeout(r, 250));
          btn = getWorkButton();
        }
      }

      if (!btn) {
        showToast('Work button not found — are you logged in with a job?', false, false, false);
        console.warn('[CoolholeQueue] Work button not in DOM');
        return;
      }

      if (btn.disabled || btn.classList.contains('work-cooling')) {
        const label = (btn.textContent || '').replace(/\s+/g, ' ').trim() || 'cooling down';
        showToast(`Work is on cooldown (${label})`, false, false, true);
        return;
      }

      btn.click();
      showToast('Work — earning CP', true, false, false);
      console.log(
        '%c[CoolholeQueue]%c Work clicked',
        'background:#1184e8;color:#fff;font-weight:700;padding:2px 8px;border-radius:4px;',
        'color:inherit;'
      );
    }

    gm.onValueChange(workKey, (_name, _old, next) => {
      if (!next?.n) return;
      doWorkAction();
    });

    gm.onValueChange(queueKey, (_name, _old, next) => {
      if (!next?.videoId) return;
      queueVideo(String(next.videoId), cleanTitle(next.title));
    });

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

    function parseHashRequest() {
      if (/(?:^|[&#])cq_work=1(?:&|$)/.test(location.hash)) {
        return { work: true };
      }
      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 }));
    }

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

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

        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) return;
      clearHash();
      if (req.work) {
        doWorkAction();
        return;
      }
      if (req.videoId) queueVideo(req.videoId, req.title);
    }
  }
})();