CH queues to Coolhole (or Q+ if full). CP focuses the hole and clicks Work. Draggable Hist/Q+ pill + attached list on Coolhole.
// ==UserScript==
// @name YouTube → Coolhole Queue Buttons
// @namespace coolhole-queue-buttons
// @version 2.6.9
// @description CH queues to Coolhole (or Q+ if full). CP focuses the hole and clicks Work. Draggable Hist/Q+ pill + attached list on Coolhole.
// @author soapylerd
// @match *://*.youtube.com/*
// @match *://youtube.com/*
// @match *://m.youtube.com/*
// @match *://youtu.be/*
// @match *://*.youtu.be/*
// @match *://*.youtube.ca/*
// @match *://youtube.ca/*
// @match *://*.youtube.co.uk/*
// @match *://youtube.co.uk/*
// @match *://*.youtube.de/*
// @match *://youtube.de/*
// @match *://*.youtube.fr/*
// @match *://youtube.fr/*
// @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.es/*
// @match *://youtube.es/*
// @match *://*.youtube.it/*
// @match *://youtube.it/*
// @match *://*.youtube.nl/*
// @match *://youtube.nl/*
// @match *://*.youtube.pl/*
// @match *://youtube.pl/*
// @match *://*.youtube.com.mx/*
// @match *://youtube.com.mx/*
// @include *://youtube.*/*
// @include *://*.youtube.*/*
// @include *://m.youtube.*/*
// @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 cdKey = 'cq_work_cd';
const settingsKey = 'cq_cp_settings';
const aliveKey = (h) => `cq_alive_${h}`;
const pendingKey = 'cq_pending';
const historyKey = 'cq_history';
const limitKey = 'cq_my_limit';
const autoKey = 'cq_auto_queue';
const collapsedKey = 'cq_float_collapsed';
const ALIVE_MAX_MS = 15_000;
const MAX_PENDING = 40;
const MAX_HISTORY = 30;
const HOLD_MS = 450;
const defaultSettings = Object.freeze({
work: true,
unAfk: false,
autoFocus: true,
qPlus: true,
ytOpacity: 0.38,
holeOpacity: 0.35,
});
function loadSettings() {
try {
const raw = gm.getValue(settingsKey, null);
if (raw && typeof raw === 'object') return { ...defaultSettings, ...raw };
} catch {
/* ignore */
}
return { ...defaultSettings };
}
function saveSettings(next) {
try {
gm.setValue(settingsKey, { ...defaultSettings, ...next });
} catch (e) {
console.warn('[CoolholeQueue] saveSettings failed', e);
}
}
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) || /(^|\.)youtu\.be$/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;
transition: opacity .2s ease;
}
.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;
position: relative;
overflow: hidden;
z-index: 0;
}
/* Fill grows left → right. Visible on both gray and blue hover. */
.cq-pill .cq-seg-cp .cq-cp-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0%;
background: #9e9e9e;
z-index: 0;
pointer-events: none;
transition: width 0.3s linear;
}
.cq-pill .cq-seg-cp > * {
position: relative;
z-index: 1;
}
/* Ready = fully filled look (same tone as base so it reads as charged) */
.cq-pill .cq-seg-cp.cq-ready .cq-cp-fill {
width: 100% !important;
background: #6e6e6e;
}
.cq-pill .cq-seg-cp.cq-ready:hover .cq-cp-fill {
background: transparent;
}
.cq-pill .cq-seg-ch:hover {
background: linear-gradient(180deg, #e53935, #b71c1c);
}
/* Original blue hover — fill stays fully visible underneath */
.cq-pill .cq-seg-cp:hover {
background: linear-gradient(180deg, #1184e8, #0a5fad);
}
.cq-pill .cq-seg-cp:hover .cq-cp-fill {
background: rgba(255, 255, 255, 0.28);
}
/* Hold progress — grows right → left (opposite of charge) */
.cq-pill .cq-seg-cp .cq-cp-hold {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 0%;
background: rgba(255, 255, 255, 0.22);
z-index: 0;
pointer-events: none;
transition: none;
}
.cq-pill .cq-seg-cp.cq-holding .cq-cp-hold {
transition: width 0.52s linear;
width: 100%;
}
/* CP settings menu — visibility driven by inline styles in openMenu */
#cq-cp-menu {
position: fixed;
z-index: 2147483647;
width: 260px;
background: #1e1e1e;
border: 2px solid #1184e8;
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0,0,0,.75);
font: 12px/1.35 system-ui, sans-serif;
color: #eee;
overflow: hidden;
}
#cq-cp-menu .cq-menu-head {
pointer-events: none;
}
#cq-cp-menu .cq-menu-head {
padding: 8px 10px;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.04em;
color: #90caf9;
border-bottom: 1px solid #333;
background: #252525;
}
#cq-cp-menu .cq-menu-body {
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 7px;
}
#cq-cp-menu label.cq-check {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
user-select: none;
color: #ddd;
}
#cq-cp-menu label.cq-check input {
margin: 0;
accent-color: #1184e8;
}
#cq-cp-menu .cq-menu-sub {
margin-left: 18px;
display: flex;
flex-direction: column;
gap: 5px;
}
#cq-cp-menu .cq-menu-sub.hidden { display: none; }
#cq-cp-menu select {
background: #2a2a2a;
color: #eee;
border: 1px solid #444;
border-radius: 4px;
padding: 3px 6px;
font-size: 11px;
}
#cq-cp-menu .cq-slider-row {
display: flex;
flex-direction: column;
gap: 3px;
}
#cq-cp-menu .cq-slider-row span {
display: flex;
justify-content: space-between;
color: #bbb;
font-size: 11px;
}
#cq-cp-menu input[type="range"] {
width: 100%;
accent-color: #1184e8;
}
#cq-cp-menu .cq-menu-foot {
padding: 6px 10px;
border-top: 1px solid #333;
background: #252525;
display: flex;
justify-content: flex-end;
gap: 6px;
}
#cq-cp-menu .cq-menu-foot button {
border: 0;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
background: #424242;
color: #ddd;
}
#cq-cp-menu .cq-menu-foot button.cq-primary {
background: #1184e8;
color: #fff;
}
#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 {
// Don't re-signal Work while the local charge bar is still cooling
try {
const cd = gm.getValue(cdKey, null);
if (cd && cd.ready === false && cd.remainingMs > 0 && cd.startedAt) {
const ends = cd.startedAt + Math.max(cd.totalMs || 0, cd.remainingMs);
if (ends > Date.now() + 500) {
const secs = Math.ceil((ends - Date.now()) / 1000);
console.log('[CoolholeQueue] Work ignored — still cooling ~' + secs + 's');
if (btn) btn.title = 'Work cooldown — ' + secs + 's · hold for settings';
return;
}
}
} catch {
/* ignore */
}
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;
let sharedPill = null;
function clearHideTimer() {
if (hideTimer) {
clearTimeout(hideTimer);
hideTimer = null;
}
}
function scheduleHide() {
clearHideTimer();
hideTimer = setTimeout(() => {
// Don't hide while CP settings menu is open
const menu = document.getElementById('cq-cp-menu');
if (menu && menu.style.display === 'block') return;
if (sharedPill) sharedPill.style.display = 'none';
}, 250);
}
function hidePillNow() {
clearHideTimer();
const menu = document.getElementById('cq-cp-menu');
if (menu && menu.style.display === 'block') return;
if (sharedPill) 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 cq-ready';
cp.title = 'Focus Coolhole and Work (Earn CP) · hold for settings';
// Fill layer (grows left → right). Hold layer grows right → left.
const fill = document.createElement('span');
fill.className = 'cq-cp-fill';
fill.style.width = '100%';
const holdFill = document.createElement('span');
holdFill.className = 'cq-cp-hold';
const label = document.createElement('span');
label.textContent = 'CP';
cp.append(fill, holdFill, label);
// ── Settings + hold menu + charge bar ─────────────────────────
let settings = loadSettings();
let menuEl = null;
let menuOpen = false;
let menuOpenedAt = 0;
let holdTimer = null;
let holdActive = false;
let holdOpenedMenu = false;
let localTimer = null;
let cycleTotal = 0;
let cycleEndsAt = 0;
let lastCdTs = 0;
const applyYtOpacity = () => {
try {
const op = Math.min(1, Math.max(0.1, Number(settings.ytOpacity) || 0.38));
pill.style.opacity = String(op);
} catch {
pill.style.opacity = '0.38';
}
};
const setHoldVisual = (on) => {
if (on) {
cp.classList.add('cq-holding');
holdFill.style.transition = 'width ' + HOLD_MS + 'ms linear';
holdFill.style.width = '0%';
void holdFill.offsetWidth;
holdFill.style.width = '100%';
} else {
cp.classList.remove('cq-holding');
holdFill.style.transition = 'none';
holdFill.style.width = '0%';
}
};
const setReady = () => {
cp.classList.add('cq-ready');
fill.style.width = '100%';
cp.title = 'Focus Coolhole and Work (Earn CP) · hold for settings';
if (localTimer) {
clearInterval(localTimer);
localTimer = null;
}
};
const tickLocal = () => {
const rem = Math.max(0, cycleEndsAt - Date.now());
if (rem <= 0) {
setReady();
return;
}
cp.classList.remove('cq-ready');
const total = Math.max(cycleTotal, 1000);
const pct = Math.min(100, Math.max(0, ((total - rem) / total) * 100));
fill.style.width = pct.toFixed(1) + '%';
cp.title = 'Work cooldown — ' + Math.ceil(rem / 1000) + 's · hold for settings';
};
const startLocalCountdown = (state) => {
if (!state) return;
// Ready / blocked → only clear if we were actually done or forced ready
if (state.ready || !(state.remainingMs > 0)) {
if (state.ready && (!localTimer || cycleEndsAt <= Date.now())) setReady();
return;
}
const ts = state.ts || state.startedAt || 0;
if (ts && ts === lastCdTs) return;
const started = state.startedAt || state.ts || Date.now();
const total = Math.max(state.totalMs || 0, state.remainingMs, 1000);
const newEndsAt = started + total;
// If a countdown is already running for this cycle, don't reset the bar
if (localTimer && cycleEndsAt > Date.now()) {
const sameCycle = Math.abs(newEndsAt - cycleEndsAt) < 3000;
if (sameCycle) {
if (ts) lastCdTs = ts;
return;
}
}
if (ts) lastCdTs = ts;
cycleTotal = total;
cycleEndsAt = newEndsAt;
cp.classList.remove('cq-ready');
fill.style.width = '0%';
void fill.offsetWidth;
tickLocal();
if (localTimer) clearInterval(localTimer);
localTimer = setInterval(tickLocal, 200);
};
// --- Menu (built on first open — never "missing") ---
const hideMenu = () => {
if (!menuEl) return;
menuEl.style.display = 'none';
menuOpen = false;
// Apply saved transparency once menu is gone
applyYtOpacity();
};
const ensureMenu = () => {
if (menuEl && menuEl.isConnected) return menuEl;
if (menuEl && !menuEl.isConnected) {
(document.body || document.documentElement).appendChild(menuEl);
return menuEl;
}
// YouTube Trusted Types blocks innerHTML — build with DOM APIs only
const el = document.createElement('div');
el.id = 'cq-cp-menu';
const head = document.createElement('div');
head.className = 'cq-menu-head';
head.textContent = 'CP SETTINGS';
const body = document.createElement('div');
body.className = 'cq-menu-body';
const mkCheck = (key, labelText) => {
const lab = document.createElement('label');
lab.className = 'cq-check';
const inp = document.createElement('input');
inp.type = 'checkbox';
inp.dataset.k = key;
lab.appendChild(inp);
lab.appendChild(document.createTextNode(' ' + labelText));
return lab;
};
body.appendChild(mkCheck('work', 'Work'));
body.appendChild(mkCheck('unAfk', 'Un-AFK'));
body.appendChild(mkCheck('autoFocus', 'Auto-focus tab'));
body.appendChild(mkCheck('qPlus', 'Q+ features'));
const mkSlider = (key, labelText, defaultPct) => {
const row = document.createElement('div');
row.className = 'cq-slider-row';
const span = document.createElement('span');
span.appendChild(document.createTextNode(labelText + ' '));
const bold = document.createElement('b');
bold.dataset.v = key;
bold.textContent = defaultPct + '%';
span.appendChild(bold);
const range = document.createElement('input');
range.type = 'range';
range.min = '10';
range.max = '100';
range.step = '1';
range.dataset.k = key;
range.value = String(defaultPct);
row.appendChild(span);
row.appendChild(range);
return row;
};
body.appendChild(mkSlider('ytOpacity', 'YouTube transparency', 38));
body.appendChild(mkSlider('holeOpacity', 'Hole transparency', 35));
el.appendChild(head);
el.appendChild(body);
el.addEventListener('mousedown', (e) => e.stopPropagation());
el.addEventListener('click', (e) => e.stopPropagation());
// Auto-save + live apply on any change (no Save/Close buttons)
const persistFromUI = () => {
try {
const next = { ...loadSettings() };
el.querySelectorAll('input[data-k]').forEach((inp) => {
if (inp.type === 'checkbox') next[inp.dataset.k] = inp.checked;
});
const ytR = el.querySelector('input[data-k="ytOpacity"]');
const holeR = el.querySelector('input[data-k="holeOpacity"]');
if (ytR) next.ytOpacity = Math.min(1, Math.max(0.1, Number(ytR.value) / 100));
if (holeR) next.holeOpacity = Math.min(1, Math.max(0.1, Number(holeR.value) / 100));
settings = next;
saveSettings(next);
// Same storage as Coolhole drag double-click (hide / show Hist+Q+)
gm.setValue(collapsedKey, next.qPlus === false);
applyYtOpacity();
} catch (err) {
console.warn('[CoolholeQueue] auto-save failed', err);
}
};
el.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener('change', persistFromUI);
});
el.querySelectorAll('input[type="range"]').forEach((r) => {
r.addEventListener('input', () => {
const b = el.querySelector('[data-v="' + r.dataset.k + '"]');
if (b) b.textContent = r.value + '%';
// Live transparency while dragging
if (r.dataset.k === 'ytOpacity') {
const op = Math.min(1, Math.max(0.1, Number(r.value) / 100));
pill.style.opacity = String(op);
}
persistFromUI();
});
});
(document.body || document.documentElement).appendChild(el);
menuEl = el;
return el;
};
const syncMenuUI = () => {
if (!menuEl) return;
settings = loadSettings();
// Mirror Coolhole collapse state into Q+ features checkbox
const collapsed = gm.getValue(collapsedKey, false) === true;
settings = { ...settings, qPlus: !collapsed };
menuEl.querySelectorAll('input[data-k]').forEach((inp) => {
if (inp.type === 'checkbox') {
if (inp.dataset.k === 'qPlus') inp.checked = !collapsed;
else inp.checked = !!settings[inp.dataset.k];
}
});
const ytR = menuEl.querySelector('input[data-k="ytOpacity"]');
const holeR = menuEl.querySelector('input[data-k="holeOpacity"]');
if (ytR) {
ytR.value = String(Math.round((settings.ytOpacity || 0.38) * 100));
const b = menuEl.querySelector('[data-v="ytOpacity"]');
if (b) b.textContent = ytR.value + '%';
}
if (holeR) {
holeR.value = String(Math.round((settings.holeOpacity || 0.35) * 100));
const b = menuEl.querySelector('[data-v="holeOpacity"]');
if (b) b.textContent = holeR.value + '%';
}
};
document.addEventListener('mousedown', (e) => {
if (!menuOpen || !menuEl) return;
if (Date.now() - menuOpenedAt < 500) return;
if (menuEl.contains(e.target) || cp.contains(e.target) || pill.contains(e.target)) return;
hideMenu();
});
const openMenu = () => {
try {
const el = ensureMenu();
syncMenuUI();
clearHideTimer();
pill.style.display = 'inline-flex';
pill.style.opacity = '1';
// Center under the whole CH/CP pill, clamp so it stays on-screen
const pillRect = pill.getBoundingClientRect();
const menuW = 260;
const menuH = 220; // approx height for flip decision
let left = pillRect.left + pillRect.width / 2 - menuW / 2;
left = Math.max(8, Math.min(left, window.innerWidth - menuW - 8));
let top = pillRect.bottom + 6;
if (top + menuH > window.innerHeight - 8) {
// flip above the pill if not enough room below
top = Math.max(8, pillRect.top - menuH - 6);
}
el.style.cssText =
'display:block !important;visibility:visible !important;opacity:1 !important;' +
'position:fixed !important;z-index:2147483647 !important;' +
'left:' + left + 'px;top:' + top + 'px;width:' + menuW + 'px;' +
'background:#1e1e1e;border:2px solid #1184e8;border-radius:8px;' +
'box-shadow:0 12px 40px rgba(0,0,0,.75);color:#eee;' +
'font:12px/1.35 system-ui,sans-serif;overflow:hidden;pointer-events:auto;';
menuOpen = true;
menuOpenedAt = Date.now();
holdOpenedMenu = true;
} catch (err) {
console.error('[CoolholeQueue] openMenu error', err);
}
};
// --- Hold → menu / short press → Work ---
// Uses press duration on release (most reliable on YouTube).
// Also opens mid-hold once HOLD_MS is reached.
let pressStartedAt = 0;
const clearHold = () => {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
setHoldVisual(false);
holdActive = false;
pressStartedAt = 0;
};
const onPressStart = (event) => {
if (event.button != null && event.button !== 0) return;
// Ignore duplicate mousedown after pointerdown
if (holdActive && pressStartedAt && Date.now() - pressStartedAt < 50) return;
event.preventDefault();
event.stopPropagation();
clearHideTimer();
pill.style.display = 'inline-flex';
pill.style.opacity = '1';
if (holdTimer) clearTimeout(holdTimer);
holdActive = true;
holdOpenedMenu = false;
pressStartedAt = Date.now();
setHoldVisual(true);
try {
if (event.pointerId != null) cp.setPointerCapture(event.pointerId);
} catch {
/* ignore */
}
holdTimer = setTimeout(() => {
holdTimer = null;
if (!holdActive) return;
holdOpenedMenu = true;
setHoldVisual(false);
openMenu();
}, HOLD_MS);
};
let endLockUntil = 0;
const onPressEnd = (event) => {
if (event && event.button != null && event.button !== 0) return;
if (!holdActive && !pressStartedAt) return;
// Debounce duplicate pointerup + mouseup
if (Date.now() < endLockUntil) return;
endLockUntil = Date.now() + 80;
const heldFor = pressStartedAt ? Date.now() - pressStartedAt : 0;
const opened = holdOpenedMenu;
try {
if (event && event.pointerId != null) cp.releasePointerCapture(event.pointerId);
} catch {
/* ignore */
}
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
setHoldVisual(false);
holdActive = false;
pressStartedAt = 0;
if (opened) return;
if (heldFor >= HOLD_MS) {
holdOpenedMenu = true;
openMenu();
return;
}
sendWork(cp);
};
// Prefer pointer events; mousedown is backup for older paths
if (window.PointerEvent) {
cp.addEventListener('pointerdown', onPressStart);
cp.addEventListener('pointerup', onPressEnd);
cp.addEventListener('pointercancel', onPressEnd);
} else {
cp.addEventListener('mousedown', onPressStart);
window.addEventListener('mouseup', onPressEnd);
}
// Always listen for mouseup as safety net when pointer capture fails
window.addEventListener('mouseup', onPressEnd);
cp.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
});
pill.append(ch, cp);
const mountAll = () => {
try {
const root = document.body || document.documentElement;
if (!pill.isConnected) root.appendChild(pill);
if (menuEl && !menuEl.isConnected) root.appendChild(menuEl);
applyYtOpacity();
} catch (err) {
console.error('[CoolholeQueue] pill mount failed', err);
}
};
if (document.body) mountAll();
else document.addEventListener('DOMContentLoaded', mountAll, { once: true });
pill.addEventListener('mouseenter', () => {
clearHideTimer();
pill.style.opacity = '1';
});
pill.addEventListener('mouseleave', () => {
// Keep pill visible while settings menu is open
if (menuOpen) {
pill.style.display = 'inline-flex';
pill.style.opacity = '1';
return;
}
scheduleHide();
applyYtOpacity();
});
try {
gm.onValueChange(settingsKey, (_n, _o, next) => {
if (next && typeof next === 'object') {
settings = { ...defaultSettings, ...next };
applyYtOpacity();
}
});
} catch {
/* ignore */
}
// Charge bar: live listener + poll fallback (some managers miss value-change)
try {
startLocalCountdown(gm.getValue(cdKey, null));
} catch {
/* ignore */
}
try {
gm.onValueChange(cdKey, (_n, _o, next) => startLocalCountdown(next));
} catch {
/* ignore */
}
setInterval(() => {
try {
const state = gm.getValue(cdKey, null);
if (state && state.ts && state.ts !== lastCdTs) {
startLocalCountdown(state);
}
} catch {
/* ignore */
}
}, 1000);
return pill;
}
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',
'Fed the filters: {title}',
'Another offering for the hole: {title}',
'Playlist thickened with "{title}"',
'Chat will see "{title}" soon enough',
'Slipped "{title}" past the gate',
];
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.',
'Filters shrugged. Try Add manually.',
'The hole blinked — queue it by hand this time.',
];
const MAX_QUEUE_MSG =
'Your slots in the hole are full — wait for one of yours to play, then try again.';
const ADDED_TO_Q_LINES = [
'Parked in Q+ — next free slot is yours',
'Holding in Q+ until the hole has room',
'Q+ caught it — auto-queues when a slot opens',
'Stashed in Q+ (top of the list goes in first)',
'In the waiting hole — Q+ has it',
];
const qPlusToast = () => pick(ADDED_TO_Q_LINES);
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
const formatLine = (template, title) =>
template.replaceAll('{title}', title || 'Unknown video');
// ── storage helpers ──────────────────────────────────────────────
function loadPending() {
const raw = gm.getValue(pendingKey, []);
return Array.isArray(raw) ? raw : [];
}
function savePending(list) {
gm.setValue(pendingKey, list.slice(0, MAX_PENDING));
}
function loadHistory() {
const raw = gm.getValue(historyKey, []);
return Array.isArray(raw) ? raw : [];
}
function saveHistory(list) {
gm.setValue(historyKey, list.slice(0, MAX_HISTORY));
}
function getMyLimit() {
const n = gm.getValue(limitKey, 3);
return typeof n === 'number' && n > 0 ? n : 3;
}
function setMyLimit(n) {
if (typeof n === 'number' && n > 0) gm.setValue(limitKey, n);
}
function isAutoEnabled() {
return gm.getValue(autoKey, true) !== false;
}
function setAutoEnabled(on) {
gm.setValue(autoKey, !!on);
}
/** Q+ on unless the float is collapsed (CP “Q+ features” or drag double-click). */
function isQPlusEnabled() {
return !isCollapsed();
}
function getMyName() {
return document.querySelector('#username')?.textContent?.trim() || null;
}
function getMyQueuedCount() {
const myName = getMyName();
if (!myName) return 0;
let count = 0;
document.querySelectorAll('#queue li').forEach((li) => {
const who = li.querySelector('.q-user')?.textContent?.trim();
if (who === myName) count += 1;
});
return count;
}
function addToPending(videoId, title) {
const list = loadPending();
if (list.some((x) => x.videoId === videoId)) return false;
list.push({
videoId: String(videoId),
title: cleanTitle(title) || null,
addedAt: Date.now(),
});
savePending(list);
updateQBadge();
return true;
}
function removeFromPending(videoId) {
const list = loadPending().filter((x) => x.videoId !== videoId);
savePending(list);
updateQBadge();
}
function addToHistory(videoId, title) {
const list = loadHistory().filter((x) => x.videoId !== videoId);
list.unshift({
videoId: String(videoId),
title: cleanTitle(title) || null,
queuedAt: Date.now(),
});
saveHistory(list);
}
// ── error / toast helpers (kept from 2.6.8) ──────────────────────
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 learnLimitFromError(text) {
const m = text.match(/already have (\d+)\s+items?\s+queued/i);
if (m) {
const n = parseInt(m[1], 10);
if (n > 0) setMyLimit(n);
} else {
// fallback: current count is the limit
const c = getMyQueuedCount();
if (c > 0) setMyLimit(c);
}
}
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: 11px 16px;
border-radius: 8px;
color: #fff;
font: 600 13px/1.4 system-ui, sans-serif;
z-index: 999999;
opacity: 0;
transform: translateY(12px) scale(0.97);
transition: opacity .28s ease, transform .28s ease;
pointer-events: none;
max-width: 400px;
box-shadow: 0 4px 18px rgba(0,0,0,.4);
border: 1px solid rgba(255,255,255,.08);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
#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(135deg, #ef6c00, #fb8c00 50%, #e65100) !important;
border-color: rgba(255, 183, 77, 0.35);
}
#cq-toast.cq-qplus {
background: linear-gradient(135deg, #5e35b1, #7e57c2 45%, #4527a0) !important;
border-color: rgba(179, 157, 219, 0.35);
}
#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 14px rgba(249,168,37,.4);
border-color: rgba(255, 224, 130, 0.5);
}
@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;
}
}
/* Floating Hist/Q+ pill – matches YouTube pill style */
#cq-float {
position: fixed;
z-index: 999998;
display: flex;
flex-direction: column;
align-items: flex-start;
font-family: Roboto, Arial, system-ui, sans-serif;
user-select: none;
opacity: 0.35;
transition: opacity .2s ease;
}
#cq-float:hover {
opacity: 1;
}
/* Collapsed: only a tiny restore chip */
#cq-float.cq-collapsed {
opacity: 0.22;
}
#cq-float.cq-collapsed:hover {
opacity: 0.7;
}
#cq-float.cq-collapsed #cq-float-row,
#cq-float.cq-collapsed #cq-panel {
display: none !important;
}
#cq-float-restore {
display: none;
position: relative;
cursor: pointer;
border: 0;
border-radius: 999px;
padding: 4px 9px;
font: 700 10px/1 system-ui, sans-serif;
letter-spacing: 0.04em;
color: #eee;
background: rgba(70, 70, 70, 0.55);
box-shadow: 0 1px 4px rgba(0,0,0,.35);
}
#cq-float.cq-collapsed #cq-float-restore {
display: inline-flex;
align-items: center;
}
#cq-float-restore:hover {
background: rgba(90, 90, 90, 0.85);
}
#cq-restore-badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
background: #e53935;
color: #fff;
font: 700 8px/14px system-ui, sans-serif;
text-align: center;
pointer-events: none;
}
#cq-restore-badge:empty { display: none; }
#cq-float-row {
display: inline-flex;
align-items: center;
gap: 5px;
position: relative;
}
/* Drag tab: invisible until hover near the pill */
#cq-float .cq-drag {
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
background: rgba(50, 50, 50, 0.35);
border: 1px solid transparent;
color: transparent;
font-size: 11px;
line-height: 1;
letter-spacing: -1px;
flex-shrink: 0;
opacity: 0;
transition: opacity .18s ease, background .15s ease, color .15s ease, border-color .15s ease;
}
#cq-float-row:hover .cq-drag,
#cq-float .cq-drag:active {
opacity: 1;
color: rgba(220, 220, 220, 0.85);
background: rgba(60, 60, 60, 0.55);
border-color: rgba(255, 255, 255, 0.12);
}
#cq-float .cq-drag:hover {
background: rgba(80, 80, 80, 0.8);
color: #fff;
}
#cq-float .cq-drag:active {
cursor: grabbing;
background: rgba(100, 100, 100, 0.9);
}
#cq-float-pill-wrap {
position: relative;
display: inline-flex;
}
#cq-float-pill {
display: inline-flex;
align-items: stretch;
border-radius: 999px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,.45), inset 0 0 0 1px rgba(255,255,255,.08);
line-height: 1;
}
#cq-float-pill .cq-seg {
cursor: pointer;
border: 0 !important;
margin: 0;
padding: 6px 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
color: #fff !important;
transition: background .15s ease;
min-width: 2.6em;
text-align: center;
background: #4a4a4a;
border-radius: 0 !important;
appearance: none;
-webkit-appearance: none;
outline: none;
box-shadow: none;
}
#cq-float-pill .cq-seg + .cq-seg {
border-left: 1px solid rgba(0,0,0,.25) !important;
background: #555;
}
#cq-float-pill .cq-seg:hover {
background: linear-gradient(180deg, #e53935, #b71c1c);
}
#cq-float-pill .cq-seg.cq-seg-q:hover,
#cq-float-pill .cq-seg.cq-seg-q.cq-active {
background: linear-gradient(180deg, #1184e8, #0a5fad);
}
#cq-float-pill .cq-seg.cq-seg-hist.cq-active {
background: linear-gradient(180deg, #e53935, #b71c1c);
}
/* Badge sits on the wrap so overflow:hidden on the pill does not clip it */
#cq-q-badge {
position: absolute;
top: -7px;
right: -7px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #e53935;
color: #fff;
font: 700 9px/16px system-ui, sans-serif;
text-align: center;
pointer-events: none;
box-shadow: 0 1px 4px rgba(0,0,0,.5);
z-index: 5;
}
#cq-q-badge:empty { display: none; }
/* Panel attached under the pill */
#cq-panel {
display: none;
flex-direction: column;
width: 300px;
margin-top: 6px;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,.5);
font: 12px/1.35 system-ui, sans-serif;
color: #eee;
overflow: hidden;
}
#cq-panel.open { display: flex; }
#cq-panel-body {
max-height: 108px; /* ~3 rows */
overflow-y: auto;
padding: 2px 0;
}
#cq-panel-body .cq-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-bottom: 1px solid #2a2a2a;
min-height: 28px;
box-sizing: border-box;
}
#cq-panel-body .cq-row:hover { background: #2a2a2a; }
#cq-panel-body .cq-row.cq-dragging {
opacity: 0.45;
background: #333;
}
#cq-panel-body .cq-row.cq-drag-over {
border-top: 2px solid #1184e8;
}
#cq-panel-body .cq-grip {
flex-shrink: 0;
cursor: grab;
color: #777;
font-size: 11px;
letter-spacing: -1px;
padding: 0 2px;
user-select: none;
}
#cq-panel-body .cq-grip:active { cursor: grabbing; }
#cq-panel-body .cq-row.cq-next .cq-title {
color: #90caf9;
}
#cq-panel-body .cq-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
#cq-panel-body .cq-act {
flex-shrink: 0;
border: 0;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
#cq-panel-body .cq-act-ch {
background: #c62828;
color: #fff;
}
#cq-panel-body .cq-act-del {
background: #424242;
color: #ccc;
}
#cq-panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-top: 1px solid #333;
background: #252525;
font-size: 11px;
}
#cq-panel-footer label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
user-select: none;
}
#cq-panel-footer button {
border: 0;
border-radius: 3px;
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
background: #424242;
color: #ddd;
cursor: pointer;
}
#cq-panel-empty {
padding: 16px 10px;
text-align: center;
color: #777;
font-size: 11px;
}
`);
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', 'cq-qplus');
// ok + maxed → Q+ hold (purple). !ok + maxed → hard limit (orange).
if (maxed && ok) {
toast.style.background = '';
toast.classList.add('cq-qplus');
} else 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', 'cq-qplus');
}, maxed ? 5200 : gold ? 4200 : 3600);
}
// ── alive heartbeat ──────────────────────────────────────────────
const beat = () => gm.setValue(aliveKey(host), Date.now());
beat();
setInterval(beat, 5000);
window.addEventListener('beforeunload', () => gm.setValue(aliveKey(host), 0));
// ── Work button ──────────────────────────────────────────────────
function getWorkButton() {
return (
document.querySelector('#job-actions button.job-action-work') ||
document.querySelector('button.job-action-work') ||
document.querySelector('button[title="Earn CP"]')
);
}
function collectRecentSystemTexts(limit = 6) {
const out = [];
try {
const lines = document.querySelectorAll(
'#messagebuffer .server-whisper, #messagebuffer .chat-line.server-whisper, #messagebuffer .chat-line'
);
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
const el = lines[i];
const isSystem =
el.classList.contains('server-whisper') ||
(el.querySelector && el.querySelector('.chat-user')?.textContent?.trim() === 'System');
if (!isSystem) continue;
const textEl = el.querySelector('.chat-text') || el;
const text = (textEl.textContent || '').replace(/\s+/g, ' ').trim();
if (text) out.push(text);
}
} catch {
/* ignore */
}
return out;
}
function isWorkBlockedMessage(text) {
if (!text) return false;
const t = text.toLowerCase();
// handle curly apostrophes etc.
return (
/can[\u2019']?t work/.test(t) ||
/cannot work/.test(t) ||
/sun saps your strength/.test(t) ||
/wait for nightfall/.test(t) ||
/work by day/.test(t) ||
/not allowed to work/.test(t) ||
/you are (dead|a ghost|ghost|jailed)/.test(t) ||
(/error/.test(t) && /work/.test(t)) ||
/unable to work/.test(t) ||
/work failed/.test(t)
);
}
function findNewWorkBlock(beforeSet) {
const now = collectRecentSystemTexts(8);
for (const msg of now) {
if (beforeSet.has(msg)) continue;
if (isWorkBlockedMessage(msg)) return msg;
}
// also accept if the newest message is a block even if text matched (re-posted)
if (now[0] && isWorkBlockedMessage(now[0])) return now[0];
return null;
}
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;
}
const beforeSet = new Set(collectRecentSystemTexts(8));
const buf = document.querySelector('#messagebuffer');
let blockedMsg = null;
// Live watcher for system lines while we wait
let observer = null;
if (buf && typeof MutationObserver === 'function') {
observer = new MutationObserver(() => {
const hit = findNewWorkBlock(beforeSet);
if (hit) blockedMsg = hit;
});
try {
observer.observe(buf, { childList: true, subtree: true, characterData: true });
} catch {
observer = null;
}
}
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;'
);
// Watch ~3s for system rejection (day/night, dead, jailed, etc.)
for (let i = 0; i < 10; i++) {
await new Promise((r) => setTimeout(r, 300));
if (!blockedMsg) blockedMsg = findNewWorkBlock(beforeSet);
if (blockedMsg) {
const short =
blockedMsg.length > 100 ? blockedMsg.slice(0, 97) + '…' : blockedMsg;
showToast('Work failed — ' + short, false, false, false);
console.warn('[CoolholeQueue] Work blocked by system:', blockedMsg);
try {
gm.setValue(cdKey, {
ready: true,
remainingMs: 0,
totalMs: 0,
startedAt: Date.now(),
ts: Date.now(),
blocked: true,
reason: blockedMsg,
});
} catch {
/* ignore */
}
break;
}
const b2 = getWorkButton();
if (b2 && (b2.disabled || b2.classList.contains('work-cooling'))) {
// accepted — cooldown started
break;
}
}
if (observer) {
try {
observer.disconnect();
} catch {
/* ignore */
}
}
}
// ── Work cooldown reporter (one-shot after a *successful* Work) ──
function parseCountdownMs(text) {
if (!text) return 0;
const raw = String(text).replace(/\s+/g, ' ').trim();
const m = raw.match(/(?:(\d+)\s*:\s*)?(\d+)\s*s?\b/i);
if (m) {
const mins = m[1] ? parseInt(m[1], 10) : 0;
const secs = parseInt(m[2], 10) || 0;
return (mins * 60 + secs) * 1000;
}
const n = parseInt(raw.replace(/[^\d]/g, ''), 10);
if (n > 0 && n < 900) return n * 1000;
return 0;
}
let lastCaptureAt = 0;
function captureWorkCooldown() {
try {
// Never overwrite an in-progress cooldown snapshot
if (Date.now() - lastCaptureAt < 8000) {
const existing = gm.getValue(cdKey, null);
if (existing && existing.ready === false && existing.remainingMs > 0) return;
}
const btn =
document.querySelector('#job-actions button.job-action-work') ||
document.querySelector('button.job-action-work') ||
document.querySelector('button[title="Earn CP"]') ||
document.querySelector('.job-ability-work') ||
document.querySelector('button.job-ability-work');
const cdEl = document.querySelector('#jcd-work');
const cooling =
!!btn &&
(btn.disabled ||
btn.classList.contains('work-cooling') ||
(cdEl && /\d/.test(cdEl.textContent || '')));
if (!cooling) return; // Work didn't take — leave YT bar alone
const sources = [
cdEl && cdEl.textContent,
btn && btn.textContent,
btn && btn.getAttribute('title'),
btn && btn.querySelector('.job-ab-cd') && btn.querySelector('.job-ab-cd').textContent,
];
let remainingMs = 0;
for (const src of sources) {
const ms = parseCountdownMs(src);
if (ms > 0) {
remainingMs = ms;
break;
}
}
if (remainingMs <= 0) remainingMs = 30_000;
lastCaptureAt = Date.now();
gm.setValue(cdKey, {
ready: false,
remainingMs,
totalMs: remainingMs,
startedAt: Date.now(),
ts: Date.now(),
});
} catch (e) {
console.warn('[CoolholeQueue] cooldown capture failed', e);
}
}
gm.onValueChange(workKey, (_name, _old, next) => {
if (!next?.n) return;
// Snapshot ready/cooling *before* we click
const btnBefore =
document.querySelector('#job-actions button.job-action-work') ||
document.querySelector('button.job-action-work') ||
document.querySelector('button[title="Earn CP"]');
const wasCooling =
!!btnBefore &&
(btnBefore.disabled || btnBefore.classList.contains('work-cooling'));
doWorkAction().then(() => {
// Only capture a new CD if we were ready before (successful new Work)
if (wasCooling) return;
setTimeout(captureWorkCooldown, 400);
setTimeout(captureWorkCooldown, 1000);
});
});
// ── Queue helpers (original + pending aware) ─────────────────────
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 }));
}
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);
});
}
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;
}
}
let lastQueued = { id: null, at: 0 };
let autoQueueLock = false;
async function queueVideo(videoId, title, opts = {}) {
if (!videoId) return;
const fromPending = !!opts.fromPending;
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 myCount = getMyQueuedCount();
const myLimit = getMyLimit();
// Room check
if (myCount >= myLimit) {
if (!fromPending) {
if (!isQPlusEnabled()) {
// Q+ disabled (CP setting or float collapsed)
showToast(MAX_QUEUE_MSG, false, false, true);
fancyLog(displayTitle, false, false, true);
} else {
addToPending(videoId, displayTitle);
showToast(qPlusToast(), true, false, true);
fancyLog(displayTitle + ' → Q+', false, false, true);
renderPanelIfOpen();
}
}
return;
}
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));
const errText = getRecentErrorText();
if (isMaxQueueError(errText)) {
learnLimitFromError(errText);
if (!fromPending) {
if (!isQPlusEnabled()) {
showToast(MAX_QUEUE_MSG, false, false, true);
} else {
addToPending(videoId, displayTitle);
showToast(qPlusToast(), true, false, true);
renderPanelIfOpen();
}
}
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, 700));
const after = getRecentErrorText();
const fresh = after.length > before.length ? after.slice(before.length) : after;
if (isMaxQueueError(fresh) || isMaxQueueError(after)) {
learnLimitFromError(fresh || after);
if (!fromPending) {
if (!isQPlusEnabled()) {
showToast(MAX_QUEUE_MSG, false, false, true);
} else {
addToPending(videoId, displayTitle);
showToast(qPlusToast(), true, false, true);
renderPanelIfOpen();
}
}
fancyLog(displayTitle, false, false, true);
return;
}
// Success
if (fromPending) removeFromPending(videoId);
addToHistory(videoId, displayTitle);
const gold = Math.random() < 0.12;
showToast(formatLine(pick(SUCCESS_LINES), displayTitle), true, gold, false);
fancyLog(displayTitle, true, gold, false);
renderPanelIfOpen();
} catch (err) {
console.error('[CoolholeQueue] failed:', err);
const errText = String(err?.message || '') + '\n' + getRecentErrorText();
if (isMaxQueueError(errText)) {
learnLimitFromError(errText);
if (!fromPending) {
if (!isQPlusEnabled()) {
showToast(MAX_QUEUE_MSG, false, false, true);
} else {
addToPending(videoId, displayTitle);
showToast(qPlusToast(), true, false, true);
renderPanelIfOpen();
}
}
fancyLog(displayTitle, false, false, true);
} else {
showToast(pick(FAIL_LINES), false, false, false);
fancyLog(displayTitle, false, false, false);
}
if (!isCollapsed()) renderPanelIfOpen();
}
}
// Incoming from YouTube
gm.onValueChange(queueKey, (_name, _old, next) => {
if (!next?.videoId) return;
queueVideo(String(next.videoId), cleanTitle(next.title));
});
// ── Auto-queue on “one of my videos left the queue” ───────────────
let prevMyCount = getMyQueuedCount();
function tryAutoQueue() {
if (autoQueueLock || !isAutoEnabled() || !isQPlusEnabled()) return;
const pending = loadPending();
if (pending.length === 0) return;
const myCount = getMyQueuedCount();
const myLimit = getMyLimit();
if (myCount >= myLimit) return;
const next = pending[0];
if (!next?.videoId) return;
autoQueueLock = true;
queueVideo(next.videoId, next.title, { fromPending: true }).finally(() => {
setTimeout(() => {
autoQueueLock = false;
}, 1500);
});
}
function onQueueDomChange() {
const now = getMyQueuedCount();
if (now < prevMyCount) {
// one (or more) of our videos left → try to fill the slot
tryAutoQueue();
}
prevMyCount = now;
}
// Observe the queue list
const queueEl = document.querySelector('#queue');
if (queueEl) {
new MutationObserver(onQueueDomChange).observe(queueEl, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
});
}
// Light safety net only while Q+ has items
setInterval(() => {
if (loadPending().length > 0) onQueueDomChange();
}, 18_000);
// ── Hist / Q+ floating pill + attached panel ─────────────────────
const posKey = 'cq_float_pos';
let panelTab = 'hist'; // 'hist' | 'q'
let floatRoot = null;
function loadPos() {
const raw = gm.getValue(posKey, null);
if (raw && typeof raw.x === 'number' && typeof raw.y === 'number') return raw;
return { x: Math.max(16, window.innerWidth - 320), y: Math.max(80, window.innerHeight - 220) };
}
function savePos(x, y) {
gm.setValue(posKey, { x, y });
}
function isCollapsed() {
return gm.getValue(collapsedKey, false) === true;
}
function setCollapsed(on) {
on = !!on;
gm.setValue(collapsedKey, on);
try {
const s = loadSettings();
if (s.qPlus === on) saveSettings({ ...s, qPlus: !on });
} catch {
/* ignore */
}
if (!floatRoot) return;
floatRoot.classList.toggle('cq-collapsed', on);
if (on) closePanel();
updateQBadge();
}
function applyPos(x, y) {
if (!floatRoot) return;
const maxX = window.innerWidth - 40;
const maxY = window.innerHeight - 40;
x = Math.max(4, Math.min(x, maxX));
y = Math.max(4, Math.min(y, maxY));
floatRoot.style.left = `${x}px`;
floatRoot.style.top = `${y}px`;
return { x, y };
}
function updateQBadge() {
// Collapsed = Q+ off → no badges, no waiting signal
const n = !isQPlusEnabled() ? 0 : loadPending().length;
const text = n > 0 ? String(n) : '';
const badge = document.getElementById('cq-q-badge');
if (badge) badge.textContent = text;
const restoreBadge = document.getElementById('cq-restore-badge');
if (restoreBadge) restoreBadge.textContent = text;
}
function renderPanelIfOpen() {
const panel = document.getElementById('cq-panel');
if (panel?.classList.contains('open')) renderPanel();
updateQBadge();
}
function renderPanel() {
const body = document.getElementById('cq-panel-body');
const footer = document.getElementById('cq-panel-footer');
if (!body || !footer) return;
const isHist = panelTab === 'hist';
const list = isHist ? loadHistory() : loadPending();
body.innerHTML = '';
if (list.length === 0) {
body.innerHTML = `<div id="cq-panel-empty">${
isHist ? 'No history yet — queue something!' : 'Q+ is empty — drag to reorder when it fills'
}</div>`;
} else {
list.forEach((item, index) => {
const row = document.createElement('div');
row.className = 'cq-row' + (!isHist && index === 0 ? ' cq-next' : '');
row.dataset.videoId = item.videoId;
if (!isHist) {
row.draggable = true;
const grip = document.createElement('span');
grip.className = 'cq-grip';
grip.textContent = '⠿';
grip.title = 'Drag to reorder — top queues next';
row.append(grip);
row.addEventListener('dragstart', (e) => {
e.stopPropagation();
row.classList.add('cq-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.videoId);
});
row.addEventListener('dragend', () => {
row.classList.remove('cq-dragging');
body.querySelectorAll('.cq-drag-over').forEach((el) => el.classList.remove('cq-drag-over'));
});
row.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
body.querySelectorAll('.cq-drag-over').forEach((el) => el.classList.remove('cq-drag-over'));
row.classList.add('cq-drag-over');
});
row.addEventListener('dragleave', () => row.classList.remove('cq-drag-over'));
row.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
row.classList.remove('cq-drag-over');
const fromId = e.dataTransfer.getData('text/plain');
const toId = item.videoId;
if (!fromId || fromId === toId) return;
const pending = loadPending();
const fromIdx = pending.findIndex((x) => x.videoId === fromId);
const toIdx = pending.findIndex((x) => x.videoId === toId);
if (fromIdx < 0 || toIdx < 0) return;
const [moved] = pending.splice(fromIdx, 1);
pending.splice(toIdx, 0, moved);
savePending(pending);
renderPanel();
});
}
const title = document.createElement('div');
title.className = 'cq-title';
title.textContent = item.title || item.videoId;
title.title = isHist ? 'Open on YouTube' : (item.title || item.videoId);
if (isHist) {
title.style.cursor = 'pointer';
title.addEventListener('mouseenter', () => { title.style.color = '#90caf9'; });
title.addEventListener('mouseleave', () => { title.style.color = ''; });
title.addEventListener('click', (e) => {
e.stopPropagation();
const url = `https://www.youtube.com/watch?v=${item.videoId}`;
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
location.assign(url);
}
});
}
const chBtn = document.createElement('button');
chBtn.type = 'button';
chBtn.className = 'cq-act cq-act-ch';
chBtn.textContent = isHist ? 'CH' : 'Force';
chBtn.title = isHist ? 'Re-queue / add to Q+' : 'Queue now';
chBtn.addEventListener('click', (e) => {
e.stopPropagation();
queueVideo(item.videoId, item.title, { fromPending: !isHist });
});
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'cq-act cq-act-del';
delBtn.textContent = '×';
delBtn.title = 'Remove';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (isHist) {
saveHistory(loadHistory().filter((x) => x.videoId !== item.videoId));
} else {
removeFromPending(item.videoId);
}
renderPanel();
});
row.append(title, chBtn, delBtn);
body.append(row);
});
}
footer.innerHTML = '';
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (isHist) saveHistory([]);
else savePending([]);
renderPanel();
updateQBadge();
});
const autoLabel = document.createElement('label');
const autoCb = document.createElement('input');
autoCb.type = 'checkbox';
autoCb.checked = isAutoEnabled();
autoCb.addEventListener('change', () => {
setAutoEnabled(autoCb.checked);
if (autoCb.checked) tryAutoQueue();
});
autoLabel.append(autoCb, document.createTextNode(' Auto-queue'));
footer.append(clearBtn, autoLabel);
}
function openPanel(tab) {
panelTab = tab;
const panel = document.getElementById('cq-panel');
if (!panel) return;
panel.classList.add('open');
document.querySelectorAll('#cq-float-pill .cq-seg').forEach((b) => {
b.classList.toggle('cq-active', b.dataset.tab === tab);
});
renderPanel();
}
function closePanel() {
document.getElementById('cq-panel')?.classList.remove('open');
document.querySelectorAll('#cq-float-pill .cq-seg').forEach((b) => b.classList.remove('cq-active'));
}
function buildFloatUI() {
if (document.getElementById('cq-float')) return;
const root = document.createElement('div');
root.id = 'cq-float';
// Row: drag tab + pill
const row = document.createElement('div');
row.id = 'cq-float-row';
const drag = document.createElement('div');
drag.className = 'cq-drag';
drag.title = 'Drag to move';
drag.textContent = '⠿';
const pillWrap = document.createElement('div');
pillWrap.id = 'cq-float-pill-wrap';
const pill = document.createElement('div');
pill.id = 'cq-float-pill';
const histBtn = document.createElement('button');
histBtn.type = 'button';
histBtn.className = 'cq-seg cq-seg-hist';
histBtn.dataset.tab = 'hist';
histBtn.textContent = 'Hist';
histBtn.title = 'Your previously queued videos';
const qBtn = document.createElement('button');
qBtn.type = 'button';
qBtn.className = 'cq-seg cq-seg-q';
qBtn.dataset.tab = 'q';
qBtn.textContent = 'Q+';
qBtn.title = 'Waiting list (auto-queue when a slot opens)';
const badge = document.createElement('span');
badge.id = 'cq-q-badge';
pill.append(histBtn, qBtn);
pillWrap.append(pill, badge);
row.append(drag, pillWrap);
// Tiny restore chip (shown only when collapsed)
const restore = document.createElement('button');
restore.type = 'button';
restore.id = 'cq-float-restore';
restore.title = 'Show Hist / Q+';
restore.textContent = 'Q+';
const restoreBadge = document.createElement('span');
restoreBadge.id = 'cq-restore-badge';
restore.append(restoreBadge);
// Panel (attached under the row)
const panel = document.createElement('div');
panel.id = 'cq-panel';
panel.innerHTML = `
<div id="cq-panel-body"></div>
<div id="cq-panel-footer"></div>
`;
root.append(row, restore, panel);
(document.body ?? document.documentElement).append(root);
floatRoot = root;
// Apply saved hole transparency (from CP settings menu)
const applyHoleOpacity = () => {
try {
const s = loadSettings();
const op = Math.min(1, Math.max(0.1, Number(s.holeOpacity) || 0.35));
root.style.opacity = String(op);
root.addEventListener('mouseenter', () => {
root.style.opacity = '1';
});
root.addEventListener('mouseleave', () => {
if (!root.classList.contains('cq-collapsed')) {
root.style.opacity = String(op);
} else {
root.style.opacity = String(Math.max(0.1, op * 0.6));
}
});
} catch {
/* ignore */
}
};
applyHoleOpacity();
gm.onValueChange(settingsKey, () => applyHoleOpacity());
// Live-sync when CP settings toggles “Q+ features” (writes collapsedKey)
gm.onValueChange(collapsedKey, () => {
if (!floatRoot) return;
const on = isCollapsed();
floatRoot.classList.toggle('cq-collapsed', on);
if (on) closePanel();
updateQBadge();
});
// Restore position + collapsed state
const pos = loadPos();
applyPos(pos.x, pos.y);
if (isCollapsed()) root.classList.add('cq-collapsed');
// Drag logic + double-tap to collapse
// (native dblclick is blocked by pointerdown preventDefault, so we detect it ourselves)
let dragging = false;
let startX = 0, startY = 0, origX = 0, origY = 0;
let dragMoved = false;
let lastTapAt = 0;
const onMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
applyPos(origX + dx, origY + dy);
};
const onUp = () => {
if (!dragging) return;
dragging = false;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
const rect = root.getBoundingClientRect();
savePos(rect.left, rect.top);
// Double-tap (no drag) → collapse
if (!dragMoved) {
const now = Date.now();
if (now - lastTapAt < 380) {
lastTapAt = 0;
setCollapsed(true);
} else {
lastTapAt = now;
}
} else {
lastTapAt = 0;
}
};
drag.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
dragging = true;
dragMoved = false;
startX = e.clientX;
startY = e.clientY;
const rect = root.getBoundingClientRect();
origX = rect.left;
origY = rect.top;
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
});
drag.title = 'Drag to move · double-click to hide';
// Click restore chip → expand
restore.addEventListener('click', (e) => {
e.stopPropagation();
setCollapsed(false);
});
// Toggle panels
histBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.classList.contains('open') && panelTab === 'hist') closePanel();
else openPanel('hist');
});
qBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (panel.classList.contains('open') && panelTab === 'q') closePanel();
else openPanel('q');
});
// Click outside to close
document.addEventListener('click', (e) => {
if (!panel.classList.contains('open')) return;
if (root.contains(e.target)) return;
closePanel();
});
// Keep on screen on resize
window.addEventListener('resize', () => {
const rect = root.getBoundingClientRect();
applyPos(rect.left, rect.top);
});
updateQBadge();
}
// Mount once body is ready
function tryMountUI() {
if (!document.body) return;
buildFloatUI();
}
if (document.body) tryMountUI();
else document.addEventListener('DOMContentLoaded', tryMountUI, { once: true });
// Safety re-mount if something wipes it
setTimeout(tryMountUI, 1500);
setTimeout(tryMountUI, 4000);
// ── Hash support (open-from-YouTube) ──────────────────────────────
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 = '';
}
}
function processHash() {
const req = parseHashRequest();
if (!req) return;
clearHash();
if (req.work) {
doWorkAction();
return;
}
if (req.videoId) queueVideo(req.videoId, req.title);
}
// Initial auto-queue attempt in case we already have pending + room
setTimeout(tryAutoQueue, 2000);
}
})();