21/10/2025, 20:41:33
// ==UserScript==
// @name Better bateworld.com
// @namespace Circlejerk Scripts
// @match https://bateworld.com//html5-chat/chat2/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_getValues
// @grant GM_setValue
// @grant GM_setValues
// @grant GM_listValues
// @version 1.2.2
// @author -
// @description 21/10/2025, 20:41:33
// @license GPL-3.0-or-later
// ==/UserScript==
GM_addStyle(`
.panel-action {
position: absolute;
left: 3px;
}
.panel-action-btn {
cursor: pointer;
margin-left: 8px;
background: rgba(255, 255, 255, 0.8);
color: inherit;
font-size: 14px;
float: right;
position: relative;
border: 1px solid #DDD;
top: 2px;
height: 15px;
line-height: 0;
opacity: 1;
transition: opacity .2s ease-out;
}
.jsPanel:not(:hover) .panel-action-btn {
opacity: 0;
}
.jsPanel .speaks {
width: 2px;
}
.jsPanel .jsPanel-content {
background: #111;
}
.jsPanel[data-status="--"] [data-status-value="--"] { background: rgba(230, 160, 140);}
.jsPanel[data-status="-"] [data-status-value="-"] { background: rgba(238, 233, 200);}
.jsPanel[data-status="+"] [data-status-value="+"] { background: rgba(120, 200, 230);}
.jsPanel[data-status="++"] [data-status-value="++"] { background: rgba(100, 190, 250);}
.jsPanel[data-status="undefined"] .jsPanel-title {
text-decoration-color: orange;
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
}
.jsPanel[data-status="undefined"] button:where([data-status-value="++"]) { display: none; }
.jsPanel[data-status="--"] button:where([data-status-value="++"]) { display: none; }
.jsPanel[data-status="-"] button:where([data-status-value="++"]) { display: none; }
.jsPanel[data-status="+"] button:where([data-status-value="--"]) { display: none; }
.jsPanel[data-status="++"] button:where([data-status-value="--"]) { display: none; }
.jsPanel:where([data-rotation=""],[data-rotation="0"]) video { rotate: 0deg; }
.jsPanel[data-rotation="90"] video { rotate: 90deg; }
.jsPanel[data-rotation="180"] video { rotate: 180deg; }
.jsPanel[data-rotation="270"] video { rotate: 270deg; }
#userList .userItem:has(i.lock.fa-lock) .userLabel {
color: rgba(255, 0, 0, .75);
}
.jsPanel {
box-shadow: none;
border-color: #888 !important;
}
#tabsAndFooter {
width: min(50%, 800px);
}
video.mobile.mobile {
max-width: 100% !important;
max-height: 100% !important;
}
.jsPanel-headerbar {
min-height: 18px;
}
.jsPanel-titlebar {
min-height: 16px;
}
.jsPanel-titlebar h3 {
margin-block: 1px;
}
`);
/** @type {'' | 'new' | 'top'} */
let algo = '';
const dynamicStyle = GM_addStyle(getCSS());
const dynamicOpenedStyle = GM_addStyle();
const PANEL_SELECTOR = '.jsPanel.jsPanel-theme-default';
// Action definitions: label and handler per action
const PANEL_ACTIONS = [
{
icon: '--',
label: 'Off',
handler: ({id, panel}) => {
GM_setValue(`${id}_status`, '--');
dynamicStyle.innerHTML = getCSS();
if (!panel) return;
panel.dataset.status = '--';
jsPanel.activePanels.getPanel(panel.id)?.close();
}
},
{
icon: '-',
label: 'Fade',
handler: ({id, panel}) => {
GM_setValue(`${id}_status`, '-');
dynamicStyle.innerHTML = getCSS();
if (!panel) return;
panel.dataset.status = '-';
}
},
{
icon: '+',
label: 'Good',
handler: ({id, panel}) => {
GM_setValue(`${id}_status`, '+');
dynamicStyle.innerHTML = getCSS();
if (!panel) return;
panel.dataset.status = '+';
}
},
{
icon: '++',
label: 'Great',
handler: ({id, panel}) => {
GM_setValue(`${id}_status`, '++');
dynamicStyle.innerHTML = getCSS();
if (!panel) return;
panel.dataset.status = '++';
}
},
{
icon: '⟳',
label: 'Rotate',
handler: ({id, panel}) => {
if (!panel) return;
const currentRotation = getRotation(panel);
const newRotation = (currentRotation + 90) % 360;
panel.dataset.rotation = newRotation;
GM_setValue(`${id}_rotation`, newRotation);
}
},
{
icon: '⏱',
label: 'Cooldown 15m',
handler: cooldownIt
},
].reverse();
function cooldownIt({id, minutes = 15, panel}) {
const expiry = Date.now() + minutes * 60 * 1000;
setCooldown(id, expiry);
console.log(`User ${id} put on cooldown until`, new Date(expiry).toISOString());
if (!panel) return;
jsPanel.activePanels.getPanel(panel.id)?.close();
};
// Observe DOM for dynamic panels
const observerPanels = new MutationObserver(mutations => {
let cleanupNeeded = false;
for (const mutation of mutations) {
// Handle added nodes
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const panels = node.matches(PANEL_SELECTOR)
? [node]
: node.querySelectorAll(PANEL_SELECTOR);
if (!panels.length) continue;
panels.forEach(attachPanelActions);
cleanupNeeded = true;
}
// Handle removed nodes
for (const node of mutation.removedNodes) {
if (!(node instanceof HTMLElement)) continue;
const panels = node.matches(PANEL_SELECTOR)
? [node]
: node.querySelectorAll(PANEL_SELECTOR);
if (!panels.length) continue;
panels.forEach(cleanupPanel);
cleanupNeeded = true;
if (algo) {
setTimeout(() => {
document.querySelector(`button[data-sort-order="${algo}"]`)?.click();
}, 32);
}
}
}
if (cleanupNeeded) updateCssForOpenedPanels();
});
observerPanels.observe(document.body, { childList: true, subtree: false, });
// Attach control buttons for each declared action
function attachPanelActions(panel) {
if (panel.dataset.actionsAttached === '1') return;
const panelJS = jsPanel.activePanels.getPanel(panel.id);
if (!panelJS) return console.warn('Panel not found for', panel.id);
panelJS.resize({ width: 365, height: 318 });
const header = panel.querySelector('.jsPanel-hdr .jsPanel-title');
if (!header) return;
const id = getUsername(panel);
panel.dataset.username = id;
panel.dataset.status = GM_getValue(`${id}_status`);
const actions = document.createElement('div');
actions.className = 'panel-action';
getPanelActions({id, panel})
.forEach(action => actions.appendChild(action));
header.appendChild(actions);
panel.dataset.actionsAttached = '1';
panel.dataset.rotation = GM_getValue(`${id}_rotation`);
panel.querySelector('video').volume = 0.08;
panel.querySelector('.jsPanel-btn.jsPanel-btn-close')?.addEventListener('click', () => {cooldownIt({id, minutes: 1});});
}
function getPanelActions(data) {
return PANEL_ACTIONS.map(action => {
const btn = document.createElement('button');
btn.dataset.statusValue = action.icon;
btn.textContent = action.icon;
btn.title = action.label;
btn.className = 'panel-action-btn';
btn.addEventListener('click', e => {
e.stopPropagation();
action.handler(data);
});
return btn;
});
}
function getMenuActions() {
return PANEL_ACTIONS
.filter(action => !['Rotate'].includes(action.label))
.map(action => {
const btn = document.createElement('button');
btn.dataset.statusValue = action.icon;
btn.textContent = action.icon;
btn.title = action.label;
btn.className = 'panel-action-btn';
btn.addEventListener('click', e => {
e.stopPropagation();
action.handler({ id: getLatestUser(), panel: null });
});
return btn;
});
}
function cleanupPanel(panel) {
}
function updateCssForOpenedPanels() {
const openedPanels = document.querySelectorAll(PANEL_SELECTOR);
if (!openedPanels?.length) return;
const usernames = Array.from(openedPanels).map(panel => getUsername(panel));
dynamicOpenedStyle.innerHTML = `
#userList .userItem:where(
${usernames.map(id => `[data-username="${id}"],[data-username^="${id}_"]`).join(',')}
) .webcamBtn {
background: #50ce85 !important;
}
`;
}
// Utility
function getRotation(element) {
if (!element || !element.dataset.rotation) return 0;
return parseInt(element.dataset.rotation, 10) || 0;
}
function getUsername(panel) {
const title = panel.querySelector('.jsPanel-title');
const username = title.childNodes.values().find(e => e.nodeType === 3 && e.nodeValue.trim()).nodeValue.trim();
return username.split('_')[0];
}
function getCSS() {
const arrayOfKeys = GM_listValues().filter(key => key.match(/\.*?_status/));
const values = GM_getValues(arrayOfKeys);
const groups = {'--': [], '-': [], '+': [], '++': []};
for(const [key, value] of Object.entries(values)) {
const [id] = key.split('_');
groups[value].push(id);
}
const dataUsername = id => `[data-username="${id}"],[data-username^="${id}_"]`;
const css = `#userList .userItem:where(
${groups['--'].map(dataUsername).join(',')}
) {
opacity: 0.3 !important;
}
#userList .userItem:where(
${groups['-'].map(dataUsername).join(',')}
) .userLabel {
color: rgba(0, 0, 0, 0.6);
}
#userList .userItem:where(
${groups['+'].map(dataUsername).join(',')}
) .userLabel {
color: rgba(0, 0, 0, 1);
font-weight: 700;
}
#userList .userItem:where(
${groups['++'].map(dataUsername).join(',')}
) .userLabel {
color: rgba(0, 0, 230, 1);
font-weight: 700;
}
`;
return css;
}
// Cooldown helpers: store expiry timestamps (ms since epoch) using GM_setValue
function setCooldown(id, expiryMs) {
GM_setValue(`${id}_cooldown`, expiryMs);
}
function getCooldownExpiry(id) {
const val = GM_getValue(`${id}_cooldown`);
return val ? Number(val) : null;
}
function isOnCooldown(id) {
const expiry = getCooldownExpiry(id);
return expiry && Date.now() < expiry;
}
const topNewest = (a, b) => b.bias - a.bias || b.onlineSince - a.onlineSince;
const topRandom = (a, b) => b.bias - a.bias || Math.random() - 0.5;
const getCandidates = (
/** @type {function({item: HTMLDivElement, id: string, status: string, bias: number, onlineSince: number}, {item: HTMLDivElement, id: string, status: string, bias: number, onlineSince: number}): number} */
compareFn = topRandom,
_biases = {}
) => {
const biases = {
"--": 0,
"-": 1,
[undefined]: 2,
"+": 3,
"++": 4,
..._biases
};
/** @type {NodeList} */
const userItems = document.querySelectorAll('#userList [data-status="online"][data-webcam="true"]:not(:has(.fa.fa-lock))');
const entries = [];
for (const /** @type {HTMLDivElement} */ item of userItems.values()) {
const id = item.dataset.username.split('_')[0];
const status = GM_getValue(`${id}_status`);
// skip users explicitly faded out
if (status === "--") continue;
// skip users currently on cooldown
if (isOnCooldown(id)) continue;
entries.push({
item,
id,
status,
bias: biases[status],
onlineSince: parseInt(item.querySelector('.userLabel [data-date]')?.dataset.date || 0),
});
}
entries.sort(compareFn);
return entries;
};
function openCandidates(candidates, /** @type {number} */ limit) {
const openedPanels = document.querySelectorAll(PANEL_SELECTOR);
let openedCount = openedPanels.length || 0;
if (openedCount >= 10) return console.log('10 panels already open');
const maxToOpen = limit ? Math.min(openedCount + limit, 10) : 10;
const openedIds = new Set(Array.from(openedPanels).map(panel => getUsername(panel)));
while (openedCount < maxToOpen && candidates.length > 0) {
const candidate = candidates.shift();
if (openedIds.has(candidate.id)) continue;
tryToOpenPanel(candidate);
openedCount++;
}
organizePanels();
}
function tryToOpenPanel(
/** @type {{item: HTMLDivElement, id: string, status: string, bias: number, onlineSince: number}} */ candidate
) {
// console.log('Trying to open panel for', candidate.id);
candidate.item.querySelector('.webcamBtn').click();
}
function setupTools() {
const header = document.querySelector('#header .header-custom-btns');
{
const button = document.createElement('button');
button.textContent = 'ø';
button.title = 'Organize open panels and stop the algorithm';
button.addEventListener('click', () => {
organizePanels();
algo = '';
});
header.prepend(button);
}
{
const button = document.createElement('button');
button.textContent = 'New';
button.dataset.sortOrder = 'new';
button.title = 'Prioritize recently online';
button.addEventListener('click', () => {
algo = 'new';
openCandidates(getCandidates(topRandom, { [undefined]: 9, '+': 8 }));
});
header.prepend(button);
}
{
const button = document.createElement('button');
button.textContent = 'Top';
button.dataset.sortOrder = 'top';
button.title = 'Prioritize users, then randomize the order';
button.addEventListener('click', () => {
algo = 'top';
openCandidates(getCandidates(topRandom));
});
header.prepend(button);
}
chatHTML5.config['timeBeforeWatchingCamAgain'] = "1000";
document.querySelector('#sortWebcamtBtn')?.click();
const userMenu = document.querySelector('#userMenu');
if (userMenu) getMenuActions().forEach(action => userMenu.appendChild(action));
}
function getLatestUser() {
const muteItem = document.querySelector('#userMenu [data-action="mute"]');
const username = muteItem.textContent.split(' ').at(-1).split('_').at(0);
return username;
}
const gapX = -5;
const gapY = 5;
const positions3x3plus1 = [
() => ({ my: 'right-top', at: 'right-top', offsetX: -5, offsetY: 50 }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[0], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'left-top', of: grid[0], offsetX: gapX }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[2], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'left-top', of: grid[2], offsetX: gapX }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[4], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[1], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[3], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'right-bottom', of: grid[5], offsetY: gapY }),
(grid) => ({ my: 'right-top', at: 'left-top', of: grid[4], offsetX: gapX, offsetY: 65 }),
];
function organizePanels(positions = positions3x3plus1) {
const opened = Array.from(document.querySelectorAll(PANEL_SELECTOR));
if (!opened?.length) return;
const GRID_SIZE = 10;
const grid = Array(GRID_SIZE).fill(null);
// Split the opened panels in two groups
const groups = { prePositioned: [], newlyCreated: [] };
for (const panel of opened) {
const group = panel.dataset.gridIndex ? groups.prePositioned : groups.newlyCreated;
group.push(panel);
}
// Put the pre-positioned panels back on the same index
for (const panel of groups.prePositioned) {
if (grid[+panel.dataset.gridIndex]) {
groups.newlyCreated.unshift(panel);
} else {
grid[+panel.dataset.gridIndex] = panel;
}
}
for (const panel of groups.newlyCreated) {
const nextAvailableSlot = grid.indexOf(null);
if (nextAvailableSlot > -1) {
grid[nextAvailableSlot] = panel;
panel.dataset.gridIndex = nextAvailableSlot.toString();
}
}
grid
.map(panel => panel ? jsPanel.activePanels.getPanel(panel.id) : null)
.forEach((panel, idx, grid) => {
if (!panel) return;
const positionFn = positions[idx];
if (!positionFn) return;
panel.resize({ width: 365, height: 318 }).reposition(positionFn(grid));
});
}
function waitToBe(
/** @type {string} */ selector,
/** @type {string[]} */ attributeFilter = ['aria-hidden'],
/** @type {function(HTMLElement): boolean} */ predicate = el => el.getAttribute('aria-hidden') !== 'false',
) {
return new Promise(resolve => {
let attrObserver = null;
let domObserver = null;
function cleanup() {
if (attrObserver) { attrObserver.disconnect(); attrObserver = null; }
if (domObserver) { domObserver.disconnect(); domObserver = null; }
}
function attachAttrObserver(el) {
if (!el) return false;
if (predicate(el)) {
cleanup();
resolve(el);
return true;
}
// Watch for attribute changes
attrObserver = new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'attributes' && attributeFilter.includes(m.attributeName)) {
if (predicate(el)) {
cleanup();
resolve(el);
return;
}
}
}
});
attrObserver.observe(el, { attributes: true, attributeFilter });
return false;
}
// If element already exists, attach attribute observer
const existing = document.querySelector(selector);
if (attachAttrObserver(existing)) return;
// Otherwise watch for element being added to the DOM
domObserver = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
const found = node.matches(selector) ? node : node.querySelector(selector);
if (!found) continue;
if (attachAttrObserver(found)) {
if (domObserver) { domObserver.disconnect(); domObserver = null; }
return;
}
}
}
});
domObserver.observe(document.body, { childList: true, subtree: true });
});
}
// Initialize existing panels
document.querySelectorAll(PANEL_SELECTOR).forEach(attachPanelActions);
Promise.resolve()
.then(() => waitToBe('#roomsModal', ['aria-hidden'], el => el.getAttribute('aria-hidden') === 'false'))
.then(() => waitToBe('#roomsModal', ['aria-hidden'], el => el.getAttribute('aria-hidden') !== 'false'))
.then(() => {
console.log('Rooms modal is now hidden');
setupTools();
openCandidates(getCandidates(topRandom));
});