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