☰

Chaturbate Enhanced Plus

Seekable live playback for Chaturbate: a DVR-style buffer of several minutes (rewind, scrub, timeshift) from a single stream, a full player bar with preview frames, zoom, speed, quality, snapshot and recording, a multi-cam viewer, hover previews, chat translation, notes, live notifications, dark theme and site cleanup. Desktop and phones. Not affiliated with Chaturbate.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         Chaturbate Enhanced Plus
// @namespace    chaturbate.enhanced.plus
// @version      2.2.2
// @description  Seekable live playback for Chaturbate: a DVR-style buffer of several minutes (rewind, scrub, timeshift) from a single stream, a full player bar with preview frames, zoom, speed, quality, snapshot and recording, a multi-cam viewer, hover previews, chat translation, notes, live notifications, dark theme and site cleanup. Desktop and phones. Not affiliated with Chaturbate.
// @match        https://chaturbate.com/*
// @match        https://*.chaturbate.com/*
// @exclude      https://secure.chaturbate.com/*
// @exclude      https://*.chaturbate.com/auth/*
// @exclude      https://*.chaturbate.com/security/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js
// @grant        none
// @run-at       document-start
// @author       Chaturbate Enhanced Plus contributors
// @icon         https://chaturbate.com/favicon.ico
// @license      MIT
// ==/UserScript==


(function () {
	'use strict';

	/* ================================================================== *
	 * settings
	 * ================================================================== */

	var KEY = 'cbx-settings';
	var MULTI_KEY = 'cbx-multi-rooms';
	var MULTI_SETS_KEY = 'cbx-multi-sets';
	var HIDE_KEY = 'cbx-hidden-selectors';
	var BLOCK_KEY = 'cbx-blocked-rooms';
	var NOTES_KEY = 'cbx-room-notes';
	var POS_KEY = 'cbx-launcher-pos';
	var WATCH_KEY = 'cbx-alert-list';
	var SEEN_KEY = 'cbx-seen-online';
	var CC_KEY = 'cbx-hidden-countries';

	var DEFAULTS = {
		// sound
		blockSoundFx: true,
		tipSliderZero: true,
		tipVolume: 0,
		bgMute: true,
		// video
		inlinePreview: true,
		hoverPreview: true,
		previewInline: true,
		previewMuted: true,
		cardWatchBtn: true,
		showDuration: false,
		pipButton: true,
		siteDvr: true,
		siteKeep: 300,
		swipeSeek: true,
		holdFast: true,
		phoneHeight: 0,
		multiFill: false,
		rotateFullscreen: true,
		zoomByRoom: {},
		tipMarks: true,
		mediaSession: true,
		fsAutoHide: true,
		statsOverlay: false,
		scrubThumbs: true,
		dataSaver: false,
		dataSaverAuto: true,
		chatMuted: [],
		chatKeywords: '',
		chatTipsOnly: false,
		chatFont: 0,
		uiGen: 4,
		clipSave: false,
		keyShortcuts: true,
		dblTapSeek: true,
		autoQuality: true,
		qualityCap: 0,
		// appearance
		forceDark: true,
		hideAds: true,
		hideSocials: true,
		hideMerch: true,
		hideSurveys: true,
		darkLegacy: true,
		hidePlayerLogo: true,
		tightMargins: true,
		hideBadges: true,
		cleanProfile: true,
		// rooms
		bioInfo: false,
		cardTools: true,
		openNewTab: true,
		autoChatRules: false,
		// chat
		translateChat: false,
		translateTo: 'en',
		chatHideNotices: true,
		chatHideSubject: true,
		chatHideTips: false,
		chatHideGreys: false,
		// multi cam
		multiCam: false,
		multiShowSubject: true,
		multiHideOffline: true,
		multiAutoRemove: false,
		multiHoverAudio: false,
		multiHidePrivate: false,
		multiCols: 0,
		multiMaxHeight: 720,
		// tabs + alerts
		inactivePause: false,
		inactiveQuality: true,
		inactiveLoad: false,
		autoRecover: true,
		errorQuality: false,
		exclusiveAudio: false,
		alertsOn: false,
		alertEvery: 60,
		trackSchedule: true,
		// layout
		gridSize: 0,
		moreGridSize: 0,
		hideGenderF: false,
		hideGenderM: false,
		hideGenderC: false,
		hideGenderT: false,
		time24: false,
		// panel
		uiLang: 'auto',
		randomGenders: 'f',
		randomLink: true,
		randomCount: 6,
		showLauncher: true,
		edgeSwipe: true,
		// toolbar under the player
		volumeBoost: 100,
		voiceBoost: false,
		normalize: false,
		recAudioOnly: false,
		recVideoOnly: false,
		recLowPerf: false,
		recWarnLeave: true,
		// 2.1.0: phones. engine: auto | adopt (one player: our engine under the site's own video) | overlay (the second player)
		engine: 'auto',
		tapToggles: true,
		phoneSleep: true,
		nativeScrub: true,
		multiSameTab: true,
		backCloses: true,
		launcherDrawer: true,
		debug: false
	};

	var MINIMAL_SET = ['hideAds', 'hideSocials', 'hidePlayerLogo', 'tightMargins', 'hideBadges', 'cleanProfile', 'chatHideNotices', 'chatHideSubject'];

	var S = readSettings();

	// version from the userscript header (GM_info is available even with @grant none); fallback for other loaders
	var VERSION = (function () { try { return GM_info.script.version; } catch (e) { return '2.2.2'; } })();
	function readSettings() {
		var out = {}, k;
		for (k in DEFAULTS) out[k] = DEFAULTS[k];
		try {
			var raw = JSON.parse(localStorage.getItem(KEY) || '{}');
			for (k in DEFAULTS) if (typeof raw[k] === typeof DEFAULTS[k]) out[k] = raw[k];
			var gen = raw.uiGen || 1;
			if (gen < 3) { out.bioInfo = false; }
			if (gen < 4) { out.cardWatchBtn = true; out.openNewTab = true; }
			// 1.3.0: one Rewind switch; the engine is chosen from the page, never by a setting
			if (gen < 6) { out.siteDvr = true; }
			out.uiGen = 6;
		} catch (e) {}
		out.tipVolume = Math.max(0, Math.min(100, Math.round(out.tipVolume) || 0));
		return out;
	}
	function save() { try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) {} }

	// Every log line is kept in a small ring, console or not: a phone has no console to open, so the
	// panel can copy the last few hundred lines instead (More > Copy diagnostics).
	var LOGBUF = [], LOG_MAX = 400, LOG_T0 = Date.now();
	function logStr(p) {
		if (typeof p === 'string') return p;
		if (p == null) return String(p);
		if (p instanceof Error) return p.name + ': ' + p.message;
		if (typeof p === 'object') { try { return JSON.stringify(p).slice(0, 200); } catch (e) { return Object.prototype.toString.call(p); } }
		return String(p);
	}
	function log() {
		var parts = [].slice.call(arguments);
		try {
			LOGBUF.push(((Date.now() - LOG_T0) / 1000).toFixed(1) + 's ' + parts.map(logStr).join(' ').slice(0, 700));
			if (LOGBUF.length > LOG_MAX) LOGBUF.splice(0, LOGBUF.length - LOG_MAX);
		} catch (e) {}
		if (!S.debug) return;
		try { console.log.apply(console, ['[cep]'].concat(parts)); } catch (e) {}
	}

	// The multi cam page is this same origin with a flag in the query and in the hash. On phones
	// neither can be trusted on its own: userscript managers there often inject late, after the
	// site's router has already tidied the address (the tab then shows the plain home page), and a
	// tab the phone discarded in the background is restored from that tidied address. So the tab
	// is also known by things the site cannot touch: its window name (set by window.open, it
	// survives redirects and reloads), a short-lived stamp the opener leaves in storage, and a
	// per-tab session mark that keeps a reloaded multi cam tab a multi cam tab.
	var MULTI_NAME = 'cbx-multi-tab', MULTI_HANDOFF = 'cbx-multi-handoff', MULTI_MARK = 'cbx-multi-tab';
	var THEATRE_NAME = /^cbx-theatre-([a-z0-9_]+)$/i;
	function isHomePath() { return location.pathname === '/' || location.pathname === ''; }
	// the address as it is now, plus the address this document was loaded from: the browser keeps
	// the latter in its navigation timing entry, out of reach of a router that rewrites the address
	// bar before a late script gets to look (so typed, bookmarked and shared links work as well)
	var ADDR = { q: location.search, h: location.hash };
	try {
		var navEntry = performance.getEntriesByType('navigation')[0], navUrl = navEntry && navEntry.name ? new URL(navEntry.name) : null;
		if (navUrl && navUrl.origin === location.origin) { ADDR.q += '&' + navUrl.search.slice(1); ADDR.h += '&' + navUrl.hash.slice(1); }
	} catch (e) {}
	function detectMulti() {
		var byUrl = /[?&]cbx-multi=1/.test(ADDR.q) || /(^|[#&])cbx-multi(=1)?(&|$)/.test(ADDR.h);
		var named = false, fresh = false, kept = false, top = true;
		try { top = window.top === window.self; } catch (e) { top = false; }
		try { named = window.name === MULTI_NAME; } catch (e) {}
		if (!byUrl) {
			if (!top) return false;
			if (!isHomePath()) {
				// this tab has moved on to a real page: it is no longer the multi cam tab
				if (named) { try { window.name = ''; } catch (e) {} }
				try { sessionStorage.removeItem(MULTI_MARK); } catch (e) {}
				return false;
			}
			try { var at = parseInt(localStorage.getItem(MULTI_HANDOFF), 10); fresh = !!at && Date.now() - at < 30000; } catch (e) {}
			try { kept = sessionStorage.getItem(MULTI_MARK) === '1'; } catch (e) {}
			var opened = false; try { opened = !!window.opener; } catch (e) {}
			if (!((named && (fresh || kept)) || (fresh && opened))) return false;
		}
		if (top) {
			try { window.name = MULTI_NAME; } catch (e) {}
			try { sessionStorage.setItem(MULTI_MARK, '1'); } catch (e) {}
		}
		return true;
	}
	var IS_MULTI = detectMulti();
	// what this document looked like when the script started. A multi cam tab that came up as the plain
	// home page is decided here, so this line is what a bug report needs.
	try {
		log('start: ' + location.pathname + location.search + location.hash + ' | loaded as ' + (navUrl ? navUrl.pathname + navUrl.search + navUrl.hash : '?') +
			' | window.name=' + (window.name || '-') + ' | opener=' + (function () { try { return window.opener ? 'yes' : 'no'; } catch (e) { return 'blocked'; } })() +
			' | handoff=' + (function () { try { var at = parseInt(localStorage.getItem(MULTI_HANDOFF), 10); return at ? Math.round((Date.now() - at) / 1000) + 's ago' : 'none'; } catch (e) { return '?'; } })() +
			' | readyState=' + document.readyState + ' | multi=' + IS_MULTI);
	} catch (e) {}
	function multiUrl() { return location.origin + '/?cbx-multi=1#cbx-multi'; }
	function urlSaysMulti() { return /[?&]cbx-multi=1/.test(location.search) || /(^|[#&])cbx-multi(=1)?(&|$)/.test(location.hash); }
	// history entries of ours are pushed with the browser's own method: the site's router (and our own
	// navigation hook) wrap history.pushState, and neither has any business hearing about these
	function nativePush(state, url) {
		try { History.prototype.pushState.call(history, state, '', url || location.href); return true; } catch (e) { return false; }
	}
	// The mode used to be decided once, from the address at the instant the script started. Some phone
	// browsers hand a new tab to the script before that address carries the flag, and the tab then stayed
	// a plain home page until it was reloaded by hand. Now the question is asked again whenever the
	// address can have changed, and the page switches in place.
	var multiBuilt = false, multiInPlace = false;
	function recheckMulti(why) {
		if (IS_MULTI || WATCH_POP || multiBuilt) return false;
		var top = true; try { top = window.top === window.self; } catch (e) { top = false; }
		if (!top || !isHomePath() || !urlSaysMulti()) return false;
		log('multi: the address asks for multi cam but this page started as a normal page (' + why + ') - switching in place');
		switchToMulti(why);
		return true;
	}
	function switchToMulti(why) {
		IS_MULTI = true;
		try { window.name = MULTI_NAME; } catch (e) {}
		try { sessionStorage.setItem(MULTI_MARK, '1'); } catch (e) {}
		try {
			if (ddOpen) closeDD();
			if (panelEl) openPanel(false);
			closeWatchOnly();
			stopPreview();
			if (P.block) removeDvr({ noRestore: false });
			onReady(buildMulti);
		} catch (e) {
			// the last resort is what the viewer did by hand: one reload, never a loop
			log('multi: could not switch in place (' + (e && e.message) + ')');
			var last = 0; try { last = parseInt(sessionStorage.getItem('cbx-multi-reload'), 10) || 0; } catch (e2) {}
			if (Date.now() - last > 30000) { try { sessionStorage.setItem('cbx-multi-reload', String(Date.now())); } catch (e3) {} location.reload(); }
		}
	}
	// phones: no new tab at all. From the home page the viewer is mounted right here (the multi cam page
	// always was "the home page with everything hidden"), with a history entry so Back leaves it and a
	// reload or a bookmark lands in it again. From any other page this tab simply goes there.
	function openMultiHere() {
		multiInPlace = nativePush({ cbxMulti: 1 }, '/?cbx-multi=1#cbx-multi');
		switchToMulti('same tab');
	}
	window.addEventListener('popstate', function () {
		if (!IS_MULTI || !multiInPlace || urlSaysMulti()) return;
		try { window.name = ''; } catch (e) {}
		try { sessionStorage.removeItem(MULTI_MARK); } catch (e) {}
		try { localStorage.removeItem(MULTI_HANDOFF); } catch (e) {}
		location.reload();
	});
	// open (or re-use) the multi cam tab. A named target means a second tap goes to the tab that is
	// already open instead of stacking up new ones; a blocked popup falls back to this tab.
	function openMulti() {
		var url = multiUrl();
		try { localStorage.setItem(MULTI_HANDOFF, String(Date.now())); } catch (e) {}
		if (S.multiSameTab && wantsTouchLayout()) {
			if (isHomePath()) { openMultiHere(); return; }
			try { window.name = MULTI_NAME; } catch (e) {}
			location.href = url;
			return;
		}
		var w = null;
		try { w = window.open(url, MULTI_NAME); } catch (e) {}
		if (w) { try { w.focus(); } catch (e) {} watchMultiChild(w); return; }
		try { window.name = MULTI_NAME; } catch (e) {}
		location.href = url;
	}
	// the new tab says hello on the shared channel once its viewer is up. A tab that stays silent came up
	// as the plain home page; it is reloaded once, which is what fixed it by hand.
	var multiChildUp = 0;
	function watchMultiChild(w) {
		if (!chan) return;
		var asked = Date.now();
		setTimeout(function () {
			if (multiChildUp >= asked) return;
			try { if (w && !w.closed && w.location && w.location.origin === location.origin) { log('multi: the new tab did not report in; reloading it once'); w.location.reload(); } } catch (e) {}
		}, 4000);
	}
	// leave the multi cam page for good: forget every mark first, or the home page would come up as multi cam again
	function leaveMulti(to) {
		try { window.name = ''; } catch (e) {}
		try { sessionStorage.removeItem(MULTI_MARK); } catch (e) {}
		try { localStorage.removeItem(MULTI_HANDOFF); } catch (e) {}
		location.href = to || (location.origin + '/');
	}
	// the theatre popup: same story, so the room also rides in the hash and in the window name
	var WATCH_POP = (ADDR.q.match(/[?&]cbx-watch=([a-z0-9_]+)/i) || [])[1] ||
		(ADDR.h.match(/(?:^#|&)cbx-watch=([a-z0-9_]+)/i) || [])[1] || '';
	if (!WATCH_POP && !IS_MULTI && isHomePath()) {
		try { var tn = THEATRE_NAME.exec(window.name || ''); if (tn && window.opener) WATCH_POP = tn[1].toLowerCase(); } catch (e) {}
	}

	/* ================================================================== *
	 * helpers
	 * ================================================================== */

	function $(s, r) { try { return (r || document).querySelector(s); } catch (e) { return null; } }
	function $$(s, r) { try { return [].slice.call((r || document).querySelectorAll(s)); } catch (e) { return []; } }

	function el(tag, attrs, html) {
		var n = document.createElement(tag);
		if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]);
		if (html != null) n.innerHTML = html;
		return n;
	}
	function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }

	function onReady(fn) {
		if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
		else fn();
	}

	var NON_ROOM = /^(tags|messages|accounts|affiliates|apps|search|my|api|b|static|photo_videos|supporter|tipping|external_link|privacy|terms|roomlist|feedback|contest|security|auth|signup|login|logout|terms-of-service|dmca|support|help|about)$/;

	function isRoomPath(seg) {
		if (!seg || NON_ROOM.test(seg)) return false;
		// every listing page ends in -cams: female-cams, couple-cams, new-cams, followed-cams…
		if (/-cams$/.test(seg)) return false;
		return true;
	}

	function roomName() {
		var seg = location.pathname.split('/').filter(Boolean)[0] || '';
		return isRoomPath(seg) ? seg : null;
	}

	function jsonGet(key, fb) {
		try { var v = JSON.parse(localStorage.getItem(key) || 'null'); return v == null ? fb : v; }
		catch (e) { return fb; }
	}
	function jsonSet(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) {} }


	function fmtTime(t) {
		t = Math.max(0, Math.floor(t));
		var h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60;
		return (h ? h + ':' + ('0' + m).slice(-2) : m) + ':' + ('0' + s).slice(-2);
	}

	var MAIN_PLAYER_SEL = '#video-panel,#VideoPanel,#main-video,[data-testid="room-player"],[data-testid="video-panel"],.VideoPanel,.video-player-panel,#TheaterModePlayer';

	function isLivePlaying(v) {
		return v && !v.paused && v.readyState > 2 && v.currentTime > 0;
	}

	/* ---- the part of the page that is actually on screen ----
	 * position:fixed is laid out against the layout viewport. On a phone that can be larger than what
	 * is visible: a page the browser zoomed when a small input was focused, a pinch, content that
	 * overflows sideways, a keyboard. Fixed UI of ours then landed outside the screen (the close button
	 * of the stream-only viewer, the last button of the bar, the menu "at the bottom of the page").
	 * Overlays are pinned to the visual viewport instead, through four CSS variables that are only
	 * maintained while something uses them. */
	var vpUsers = 0, vpRaf = 0, vpLast = '';
	function viewportBox() {
		var vv = window.visualViewport;
		if (vv && vv.width) return { x: vv.offsetLeft || 0, y: vv.offsetTop || 0, w: vv.width, h: vv.height, scale: vv.scale || 1 };
		return { x: 0, y: 0, w: window.innerWidth, h: window.innerHeight, scale: 1 };
	}
	function writeViewportVars() {
		vpRaf = 0;
		var st = document.documentElement.style;
		if (vpUsers <= 0) {
			if (vpLast) { vpLast = ''; ['--cbx-vx', '--cbx-vy', '--cbx-vw', '--cbx-vh'].forEach(function (k) { st.removeProperty(k); }); }
			return;
		}
		var b = viewportBox(), key = [Math.round(b.x), Math.round(b.y), Math.round(b.w), Math.round(b.h)].join(',');
		if (key === vpLast) return;
		vpLast = key;
		st.setProperty('--cbx-vx', Math.round(b.x) + 'px'); st.setProperty('--cbx-vy', Math.round(b.y) + 'px');
		st.setProperty('--cbx-vw', Math.round(b.w) + 'px'); st.setProperty('--cbx-vh', Math.round(b.h) + 'px');
		if (ddOpen) placeDD();
	}
	/* The part of an element that can be seen: its box cut down by every ancestor that clips, and by the
	 * page's own width. On the phone layout the site fills its taller portrait player by making the
	 * video (and the wrappers that share its size) wider than the screen, centred inside a container
	 * that clips: a 16:9 picture 307px tall is 546px wide on a 430px screen. Our block sits in that
	 * oversized box, so everything laid out against "the block" was laid out against 58px of nothing
	 * on each side: the pause button off the left edge, fullscreen and half the gear off the right, the
	 * live end of the timeline (the thumb, the fill, the label) beyond the screen as well. */
	function visibleRect(node, clipY) {
		var r = node.getBoundingClientRect(), L = r.left, T = r.top, R = r.right, B = r.bottom;
		var W = document.documentElement.clientWidth || window.innerWidth;
		L = Math.max(L, 0); R = Math.min(R, W);
		if (clipY) { T = Math.max(T, 0); B = Math.min(B, window.innerHeight); }
		// which ancestors clip is a matter of style, not of the moment: asked once and kept for two seconds, so
		// a drag that re-measures every frame reads a few rectangles instead of two dozen computed styles
		var memo = node._cbxClips, nowC = Date.now();
		if (!memo || nowC - memo.at > 2000 || memo.parent !== node.parentElement) {
			memo = node._cbxClips = { at: nowC, parent: node.parentElement, list: [] };
			for (var a = node.parentElement, n = 0; a && a !== document.body && a !== document.documentElement && n < 24; a = a.parentElement, n++) {
				var cs = getComputedStyle(a), clips = /^(hidden|clip|auto|scroll)$/, ox = clips.test(cs.overflowX || cs.overflow || ''), oy = clips.test(cs.overflowY || cs.overflow || '');
				if (ox || oy) memo.list.push({ el: a, x: ox, y: oy });
				if (cs.position === 'fixed') break;       // nothing above a fixed box clips it
			}
		}
		for (var i = 0; i < memo.list.length; i++) {
			var c = memo.list[i]; if (!c.el.isConnected) continue;
			var ar = c.el.getBoundingClientRect();
			if (c.x) { L = Math.max(L, ar.left); R = Math.min(R, ar.right); }
			if (c.y) { T = Math.max(T, ar.top); B = Math.min(B, ar.bottom); }
		}
		return { left: L, top: T, right: Math.max(L, R), bottom: Math.max(T, B), width: Math.max(0, R - L), height: Math.max(0, B - T), full: r };
	}
	function queueViewportVars() { if (!vpRaf) vpRaf = requestAnimationFrame(writeViewportVars); }
	function vpUse(on) {
		vpUsers = Math.max(0, vpUsers + (on ? 1 : -1));
		if (!vpUse.wired) {
			vpUse.wired = true;
			var vv = window.visualViewport;
			if (vv && vv.addEventListener) { vv.addEventListener('resize', queueViewportVars); vv.addEventListener('scroll', queueViewportVars); }
			window.addEventListener('resize', queueViewportVars);
			window.addEventListener('orientationchange', function () { setTimeout(queueViewportVars, 250); });
		}
		writeViewportVars();
	}

	/* ---- Back closes what we opened (phones) ----
	 * A full-screen viewer that only closes by its own button is a trap on a phone: the swipe or the
	 * Back button leaves the page instead. Each of ours puts one history entry down while it is open.
	 * The entry carries the state that was there, is pushed past every router hook, and its popstate
	 * is kept from the site and from our own navigation handling, so nothing reloads or rebuilds. */
	var backStack = [], backSkip = 0;
	function backPush(id, closeFn) {
		if (!S.backCloses || !wantsTouchLayout()) return;
		for (var i = 0; i < backStack.length; i++) if (backStack[i].id === id) return;
		var st = { cbxBack: id };
		try { var cur = history.state; if (cur && typeof cur === 'object') { st = {}; for (var k in cur) st[k] = cur[k]; st.cbxBack = id; } } catch (e) {}
		if (nativePush(st, null)) backStack.push({ id: id, close: closeFn });
	}
	// the page is navigating, or the thing is being rebuilt: drop our note of it and leave history alone
	function backForget(id) { backStack = backStack.filter(function (b) { return b.id !== id; }); }
	// closed by its own button: take the history entry out again, quietly
	function backPop(id) {
		for (var i = backStack.length - 1; i >= 0; i--) {
			if (backStack[i].id !== id) continue;
			backStack.splice(i, 1);
			backSkip++;
			try { history.back(); } catch (e) { backSkip--; }
			return;
		}
	}
	window.addEventListener('popstate', function (e) {
		if (backSkip > 0) { backSkip--; e.stopImmediatePropagation(); return; }
		if (!backStack.length) return;
		var top = backStack.pop();
		e.stopImmediatePropagation();
		try { top.close(); } catch (err) { log('back', top.id, err && err.message); }
	}, true);


	/* ================================================================== *
	 * sound
	 * ================================================================== */

	var blockedCount = 0;

	var webAudioBlocked = 0;

	function installWebAudioBlock() {
		// Chaturbate plays tip beeps through Web Audio. Short effects are
		// AudioBufferSourceNode / OscillatorNode; the cam stream arrives as a
		// MediaElementSource, so blocking only these two leaves audio alone.
		['AudioBufferSourceNode', 'OscillatorNode'].forEach(function (name) {
			var Ctor = window[name];
			if (!Ctor || !Ctor.prototype || !Ctor.prototype.start) return;
			var origStart = Ctor.prototype.start;
			Ctor.prototype.start = function () {
				if (S.blockSoundFx) {
					webAudioBlocked++;
					blockedCount++;
					log('blocked a Web Audio effect', name, 'total', webAudioBlocked);
					return;
				}
				return origStart.apply(this, arguments);
			};
		});

		// Belt and braces: if a buffer source is wired straight to the speakers
		// through a gain node, keep that path silent too.
		['AudioContext', 'webkitAudioContext'].forEach(function (name) {
			var Ctx = window[name];
			if (!Ctx || !Ctx.prototype || !Ctx.prototype.decodeAudioData) return;
			var orig = Ctx.prototype.decodeAudioData;
			Ctx.prototype.decodeAudioData = function () {
				if (S.blockSoundFx) log('a sound effect was decoded');
				return orig.apply(this, arguments);
			};
		});
	}

	function installSoundBlock() {
		installWebAudioBlock();
		var origPlay = HTMLMediaElement.prototype.play;
		HTMLMediaElement.prototype.play = function () {
			try {
				if (this instanceof HTMLVideoElement) {
					if (S.inlinePreview) tagInline(this);
					if (this._cbxClock && P.armed && P.video && this === P.site) { log('site play() held: parked under our player'); return Promise.resolve(); }
				} else if (S.blockSoundFx) {
					blockedCount++;
					log('blocked sound effect', this.currentSrc || this.src);
					try { this.pause(); this.muted = true; this.volume = 0; } catch (e) {}
					return Promise.resolve();
				}
			} catch (e) {}
			return origPlay.apply(this, arguments);
		};

		var OrigAudio = window.Audio;
		if (OrigAudio) {
			function PatchedAudio(src) {
				var a = new OrigAudio(src);
				try { if (S.blockSoundFx) { a.muted = true; a.volume = 0; } } catch (e) {}
				return a;
			}
			PatchedAudio.prototype = OrigAudio.prototype;
			try { window.Audio = PatchedAudio; } catch (e) {}
		}
	}

	var SEL_SLIDER = '[data-testid="tip-volume-slider"]';
	var SEL_LABEL = '[data-testid="tip-volume-value-label"]';
	var tipStatus = 'not tried yet';
	var tipDone = false;

	function readTipVolume() {
		var l = $(SEL_LABEL);
		if (!l) return null;
		var m = (l.textContent || '').match(/(\d+)\s*%/);
		return m ? parseInt(m[1], 10) : null;
	}

	function mouseDrag(slider, x, y) {
		var handle = slider.lastElementChild || slider;
		function fire(t, type) {
			t.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1 }));
		}
		function firePointer(t, type) {
			if (!window.PointerEvent) return;
			t.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0, buttons: 1, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
		}
		firePointer(handle, 'pointerdown'); fire(handle, 'mousedown');
		firePointer(document, 'pointermove'); fire(document, 'mousemove'); fire(slider, 'mousemove');
		firePointer(document, 'pointerup'); fire(document, 'mouseup');
	}

	function touchDrag(slider, x, y) {
		var handle = slider.lastElementChild || slider, touch, list;
		try {
			if (typeof document.createTouch === 'function') {
				touch = document.createTouch(window, handle, 1, x, y, x, y);
				list = document.createTouchList(touch);
			} else {
				touch = new Touch({ identifier: 1, target: handle, clientX: x, clientY: y, pageX: x, pageY: y });
				list = [touch];
			}
		} catch (e) { return false; }
		function fire(type, target) {
			var ev;
			try {
				ev = new TouchEvent(type, {
					bubbles: true, cancelable: true, view: window,
					touches: type === 'touchend' ? [] : [touch],
					targetTouches: type === 'touchend' ? [] : [touch],
					changedTouches: [touch]
				});
			} catch (e) {
				try {
					ev = document.createEvent('TouchEvent');
					ev.initTouchEvent(type, true, true, window, 0, 0, 0, x, y, false, false, false, false, list, list, list, 1, 0);
				} catch (e2) { return false; }
			}
			target.dispatchEvent(ev);
			return true;
		}
		return fire('touchstart', handle) && fire('touchmove', document) && fire('touchend', document);
	}

	// The site's slider is its own component (not an <input>), so we can only
	// drive it with events. Each rung is tried and the label read back; the
	// first one that lands within a point of the target wins. tipRung says which.
	var tipRung = '';
	function tipLanded(pct) { var v = readTipVolume(); return v !== null && Math.abs(v - pct) <= 1; }
	function trackClick(slider, x, y) {
		function fire(t, type, ctor, extra) {
			var init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: type.indexOf('up') > -1 ? 0 : 1 };
			for (var k in extra) init[k] = extra[k];
			try { t.dispatchEvent(new ctor(type, init)); } catch (e) {}
		}
		var pe = window.PointerEvent ? { pointerId: 1, pointerType: 'mouse', isPrimary: true } : null;
		if (pe) fire(slider, 'pointerdown', PointerEvent, pe);
		fire(slider, 'mousedown', MouseEvent);
		if (pe) fire(document, 'pointermove', PointerEvent, pe);
		fire(document, 'mousemove', MouseEvent); fire(slider, 'mousemove', MouseEvent);
		if (pe) fire(document, 'pointerup', PointerEvent, pe);
		fire(document, 'mouseup', MouseEvent); fire(slider, 'mouseup', MouseEvent);
		fire(slider, 'click', MouseEvent);
	}
	function setTipVolume(slider, pct) {
		var r = slider.getBoundingClientRect();
		if (!r.width) return false;
		pct = Math.max(0, Math.min(100, pct));
		// aim a whisker inside the ends so a click-to-position handler cannot clamp us out
		var x = r.left + Math.max(1, Math.min(r.width - 1, r.width * pct / 100)), y = r.top + r.height / 2;
		var rungs = [
			['track click', function () { trackClick(slider, x, y); }],
			['handle drag', function () { mouseDrag(slider, x, y); }],
			['track drag from handle', function () { var h = slider.lastElementChild || slider, hr = h.getBoundingClientRect(); mouseDrag(slider, hr.left + hr.width / 2, y); mouseDrag(slider, x, y); }],
			['touch drag', function () { touchDrag(slider, x, y); }]
		];
		for (var i = 0; i < rungs.length; i++) {
			try { rungs[i][1](); } catch (e) {}
			if (tipLanded(pct)) { tipRung = rungs[i][0]; return true; }
		}
		tipRung = 'none';
		return false;
	}

	var SETTINGS_GUESSES = [
		'[data-testid="chat-settings-btn"]', '[data-testid="chat-settings-icon"]',
		'[data-testid="settings-btn"]', '[data-testid="settings-icon"]',
		'[data-testid="chat-settings"]', '[data-testid="video-settings-btn"]'
	];

	function findSettingsToggle() {
		for (var g = 0; g < SETTINGS_GUESSES.length; g++) {
			var hit = $(SETTINGS_GUESSES[g]);
			if (hit) return hit;
		}
		var nodes = $$('[data-testid],[aria-label],[title],[id]');
		for (var i = 0; i < nodes.length; i++) {
			var n = nodes[i];
			var s = ((n.getAttribute('data-testid') || '') + ' ' + (n.getAttribute('aria-label') || '') + ' ' +
				(n.getAttribute('title') || '') + ' ' + (n.id || '')).toLowerCase();
			if (/chat.?settings|settings.?(icon|button|toggle|tab)|gear/.test(s)) {
				if (n.closest && n.closest('#cbx-panel')) continue;
				return n;
			}
		}
		return null;
	}

	function applyTipMute(attempt) {
		if (!S.tipSliderZero || tipDone) return;
		attempt = attempt || 0;
		if (attempt > 12) {
			tipStatus = 'slider not reachable — sound blocking is doing the work';
			refreshPanel(); return;
		}
		var slider = $(SEL_SLIDER);
		// present-but-hidden (settings tab closed) is the same problem as absent
		if (slider && !slider.getBoundingClientRect().width) slider = null;
		if (!slider) {
			var toggle = attempt === 0 ? findSettingsToggle() : null;
			if (toggle) {
				try { toggle.click(); } catch (e) {}
				setTimeout(function () { applyTipMute(attempt + 1); }, 450);
				return;
			}
			setTimeout(function () { applyTipMute(attempt + 1); }, 700);
			return;
		}
		var before = readTipVolume(), want = S.tipVolume;
		if (before !== null && Math.abs(before - want) <= 1) { tipDone = true; tipStatus = 'slider already at ' + before + '%'; refreshPanel(); return; }
		if (setTipVolume(slider, want)) {
			tipDone = true;
			tipStatus = 'slider set to ' + readTipVolume() + '% (was ' + before + '%) via ' + tipRung;
			if (attempt > 0) { var t = findSettingsToggle(); if (t) { try { t.click(); } catch (e) {} } }
			refreshPanel(); return;
		}
		tipStatus = 'slider found but would not move (at ' + readTipVolume() + '%, want ' + want + '%)';
		setTimeout(function () { applyTipMute(attempt + 1); }, 700);
	}

	function tipDiagnostics() {
		var slider = $(SEL_SLIDER), label = $(SEL_LABEL), toggle = findSettingsToggle();
		return [
			'status: ' + tipStatus,
			'slider in DOM: ' + (slider ? (slider.getBoundingClientRect().width ? 'yes, visible' : 'yes, hidden') : 'no'),
			'label reads: ' + (label ? label.textContent.trim() : '—'),
			'settings toggle guess: ' + (toggle ? (toggle.getAttribute('data-testid') || toggle.getAttribute('aria-label') || toggle.id || toggle.tagName) : 'none found'),
			'target: ' + (S.tipSliderZero ? S.tipVolume + '%' : 'not applied') + (tipRung ? ' · last rung: ' + tipRung : ''),
			'sound effects blocked: ' + blockedCount + ' (' + webAudioBlocked + ' via Web Audio)'
		].join('\n');
	}

	/* ================================================================== *
	 * video basics
	 * ================================================================== */

	function tagInline(v) {
		try {
			v.playsInline = true;
			v.setAttribute('playsinline', '');
			v.setAttribute('webkit-playsinline', 'true');
		} catch (e) {}
	}

	function isMainPlayer(v) {
		if (!v || !v.closest) return false;
		if (v.closest(MAIN_PLAYER_SEL) || v.closest('#cbx-watch')) return true;
		var r = v.getBoundingClientRect();
		return !!roomName() && r.width > 0.6 * window.innerWidth;
	}

	function installInlinePreview() {
		document.addEventListener('webkitbeginfullscreen', function (e) {
			if (!S.inlinePreview) return;
			var v = e.target;
			if (!(v instanceof HTMLVideoElement) || isMainPlayer(v)) return;
			try { v.webkitExitFullscreen(); } catch (err) {}
		}, true);
	}

	var priorMuted = new WeakMap();

	function applyBgMute(hidden) {
		if (!S.bgMute && hidden) return;
		$$('video').forEach(function (v) {
			if (hidden) {
				if (!priorMuted.has(v)) priorMuted.set(v, v.muted);
				v.muted = true;
			} else if (priorMuted.has(v)) {
				v.muted = priorMuted.get(v);
				priorMuted.delete(v);
			}
		});
	}

	function installBgMute() {
		document.addEventListener('visibilitychange', function () {
			if (document.hidden) { noteTabHidden(); scheduleSleep(); } else clearTimeout(sleepTimer);
			applyBgMute(document.hidden);
			applyInactive(document.hidden);
			if (!document.hidden) onTabShown('visible');
		});
		// A pause the viewer asked for has to be told from a pause the browser applied. Reading v.paused at the
		// moment the tab hides cannot do that: a phone pauses the picture first and reports "hidden" second,
		// and the way back then concluded "paused by the viewer" and never recovered anything. Our own
		// controls set the flag directly; a pause that arrives while the page is in plain view (the site's
		// own button, a headset) sets it after a moment; anything around a tab switch does not.
		document.addEventListener('pause', function (e) {
			var v = e.target;
			if (!e.isTrusted || !(v instanceof HTMLVideoElement) || IS_MULTI || !roomName() || v !== activeVideo()) return;
			// The site's own controls are hidden, so a pause of the viewer's comes from our buttons (which set the
			// flag themselves) or the headset (the media-session handler does). What is left are pauses by the
			// system: opening the tab overview pauses the picture well before the page is told it is hidden, and
			// that used to be recorded as "paused by the viewer", so nothing was restarted on the way back.
			if (Date.now() - lastPointerAt > 1500) return;
			if (muteWant && Date.now() < muteWant.until) return;      // the tap was on the speaker; a pause right after it is the browser's answer to the sound change
			if (document.hidden || P.sleeping || P.busy || Date.now() - tabShownAt < 1500) return;
			setTimeout(function () {
				if (v === activeVideo() && v.paused && !v.ended && !document.hidden && !P.sleeping && !P.busy && Date.now() - tabShownAt > 1500 && Date.now() - tabHidAt > 1500) P.userPaused = true;
			}, 900);
		}, true);
		document.addEventListener('play', function (e) { if (e.isTrusted && e.target === activeVideo()) P.userPaused = false; }, true);
		// a page restored from the back/forward cache, a tab the browser froze and thawed, a connection that came back
		window.addEventListener('pageshow', function (e) { if (e.persisted) onTabShown('pageshow'); });
		document.addEventListener('resume', function () { if (!document.hidden) onTabShown('resume'); });
		window.addEventListener('online', function () { if (!document.hidden) onTabShown('online'); });
		document.addEventListener('play', function (e) {
			if (S.bgMute && document.hidden && e.target instanceof HTMLVideoElement) {
				if (!priorMuted.has(e.target)) priorMuted.set(e.target, e.target.muted);
				e.target.muted = true;
			}
		}, true);
	}


	/* ================================================================== *
	 * site player rewind (step 1 of retiring the second player)
	 *
	 * Every MSE player trims its back buffer with SourceBuffer.remove(0, t),
	 * frees space on QuotaExceededError and retries, and snaps a playhead
	 * that fell behind its playlist window back to live by setting
	 * currentTime. Hooking those three at the browser level turns the
	 * site's own player into the rewind buffer: one stream, one decoder,
	 * whatever engine the site runs. Off means every hook passes through.
	 * ================================================================== */

	var siteDvr = { hooked: false, halvings: 0, trims: 0, quota: 0, dropped: 0, skips: 0, lastDrop: '', beats: 0, rebuilds: 0, quietUntil: 0, lastSrc: '', lastRoom: '' };
	var siteUiTap = 0;

	// the bar and the rewind live on the site's own player: no second player at all
	// true: the site player runs on Media Source (hls.js) and can be hooked. false: it plays
	// the .m3u8 natively (the mobile site) and cannot. null: no source yet, wait.
	function siteIsMse() {
		var v = P.site && P.site.isConnected ? P.site : siteVideo();
		if (!v) return null;
		// our engine under the site's own video: the blob there is ours, the site itself is still native
		if (v._cbxAdopted) return false;
		if (v.srcObject) return true;
		var s = v.currentSrc || v.src || '';
		return s ? /^blob:/.test(s) : null;
	}
	// the deep (hls.js) player runs when asked for, or as the fallback for a native site player
	// rewind on: the site's own MSE player holds the window; where the site player is native (mobile), ours steps in
	function wantDeep() { return !!S.siteDvr && siteIsMse() === false; }
	function autoDeep() { return wantDeep(); }
	function siteDvrActive() { return !!S.siteDvr && !P.forcedSite && siteIsMse() === true; }
	function siteFlavor() { return !!(P.block && P.block.classList.contains('cbx-site')); }
	// the video that holds the rewind window: ours with deep rewind, the site's otherwise
	function dvrVideo() { return siteDvrActive() ? (P.site && P.site.isConnected ? P.site : siteVideo()) : P.video; }
	function heldTarget() { return siteDvrActive() ? keepBack() : (S.siteKeep || 300); }

	// seconds kept behind the playhead; halved each time the browser's buffer quota is hit
	function keepBack() {
		var s = S.siteKeep || 300;
		if (isPhone()) s = Math.min(s, 120);
		if (dataSaverOn()) s = Math.min(s, 60);
		return Math.max(30, s >> siteDvr.halvings);
	}

	function isOurVideo(v) {
		return !!(v._cbxHls || v.classList.contains('cbx-dvr') || v.classList.contains('cbx-inline-prev') ||
			(v.closest && v.closest('#cbx-hover,#cbx-watch,#cbx-multi,#cbx-block')));
	}

	// the site <video> fed by this buffer's MediaSource; decided once per buffer,
	// null for buffers that belong to our own players (previews, multi cam, deep rewind)
	function siteBufferVideo(sb) {
		// Decided once per buffer, on purpose. A SourceBuffer cannot outlive its video: moving a MediaSource to
		// another element closes it, and closing removes its buffers (the site then makes new ones, which are
		// looked up afresh). Re-checking "is the video still on the page?" here would only lose the answer in
		// the one case where it matters: a video that is re-parented for a moment while a trim comes in.
		if (sb._cbxSite !== undefined) return sb._cbxSite;
		var ms = sb._cbxMs, url = ms && ms._cbxUrl;
		if (!ms) { sb._cbxSite = null; return null; }
		var vids = $$('video'), v = null;
		for (var i = 0; i < vids.length; i++) {
			var x = vids[i];
			if (x.srcObject === ms || (url && (x.src === url || x.currentSrc === url))) { v = x; break; }
		}
		if (!v) return null; // not attached yet; ask again on the next call
		sb._cbxSite = isOurVideo(v) ? null : v;
		return sb._cbxSite;
	}

	var msByUrl = [];
	function mediaSourceOf(video) {
		try { var h = video._cbxHls, bc = h && h.bufferController; if (bc && bc.mediaSource) return bc.mediaSource; } catch (e) {}
		var src = ''; try { var sc = video.querySelector('source'); src = (sc && sc.src) || video.currentSrc || ''; } catch (e) {}
		for (var i = msByUrl.length - 1; i >= 0; i--) if (msByUrl[i].u === src) return msByUrl[i].ms;
		return null;
	}
	function installSiteDvr() {
		var MS = window.MediaSource, MMS = window.ManagedMediaSource, SB = window.SourceBuffer;
		if (siteDvr.hooked || !(MS || MMS)) return;
		siteDvr.hooked = true;

		var ocu = URL.createObjectURL;
		URL.createObjectURL = function (o) {
			var u = ocu.apply(URL, arguments);
			try { if ((MS && o instanceof MS) || (MMS && o instanceof MMS)) {
				o._cbxUrl = u; msByUrl.push({ u: u, ms: o }); if (msByUrl.length > 12) msByUrl.shift();
				// gone once it has closed. (Not "while it reads closed": a new MediaSource reads 'closed' until it is attached.)
				// once: a MediaSource that is attached again gets a new entry and a new listener; the old one must not pile up
				if (o.addEventListener) o.addEventListener('sourceclose', function () { for (var i = msByUrl.length - 1; i >= 0; i--) if (msByUrl[i].ms === o) msByUrl.splice(i, 1); }, { once: true });
			} } catch (e) {}
			return u;
		};
		if (!SB || !SB.prototype.remove) return;
		[MS, MMS].forEach(function (Ctor) {
			if (!Ctor || !Ctor.prototype.addSourceBuffer) return;
			var asb = Ctor.prototype.addSourceBuffer;
			Ctor.prototype.addSourceBuffer = function () {
				var sb = asb.apply(this, arguments);
				try { sb._cbxMs = this; } catch (e) {}
				return sb;
			};
		});

		var orem = SB.prototype.remove;
		SB.prototype.remove = function (start, end) {
			if (S.siteDvr) {
				var v = siteBufferVideo(this);
				if (v) {
					var now = v.currentTime, floor = Math.max(0, now - keepBack());
					if (end <= now) end = Math.min(end, floor);   // back-buffer trim: stop at our floor
					else if (start < now) start = now;            // full flush (quality change): keep what is behind
					if (end <= start) end = start + 0.001;        // a no-op remove still fires updateend, so the engine's queue keeps moving
					siteDvr.trims++;
				}
			}
			return orem.call(this, start, end);
		};

		var oapp = SB.prototype.appendBuffer;
		SB.prototype.appendBuffer = function () {
			try { return oapp.apply(this, arguments); }
			catch (e) {
				if (e && e.name === 'QuotaExceededError' && S.siteDvr && siteBufferVideo(this)) {
					siteDvr.quota++;
					if (keepBack() > 30) { siteDvr.halvings++; log('site buffer quota hit; now keeping ' + keepBack() + 's'); }
				}
				throw e;
			}
		};

		// a tap on one of the site's own control buttons (its Live button, say) may move the
		// playhead forward; a tap on the picture itself (to focus the page) may not
		document.addEventListener('pointerdown', function (e) {
			var t = e.target;
			if (!t || !t.closest || t.closest('#cbx-block,#cbx-dd,#cbx-panel')) return;
			if (t.closest(MAIN_PLAYER_SEL) && t.closest('.vjs-control-bar,.theater-video-controls,button,[role="button"]')) siteUiTap = Date.now();
		}, true);
	}

	/* ---- seeks ---- */
	var timeDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'currentTime') || Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'currentTime');

	// every seek of ours goes through here so the guard can tell it from the site's
	function setTime(v, t) { P.ourSeek = true; try { v.currentTime = t; } finally { P.ourSeek = false; } }

	function bufferedRanges(v) {
		var out = [], b = v.buffered;
		for (var i = 0; i < b.length; i++) out.push([b.start(i), b.end(i)]);
		return out;
	}
	// keep seeks on held video: inside a hole the playhead would starve and the site would snap to live
	function snapToBuffered(v, t) {
		var b = v.buffered;
		if (!b || !b.length) return t;
		var best = t, dist = Infinity;
		for (var i = 0; i < b.length; i++) {
			var s0 = b.start(i), e0 = b.end(i);
			if (t >= s0 && t <= e0) return t;
			var edge = t < s0 ? s0 + 0.1 : e0 - 0.3, d = Math.abs(edge - t);
			if (d < dist) { dist = d; best = edge; }
		}
		return best;
	}
	function nextBufferedStart(v, now) {
		var b = v.buffered;
		for (var i = 0; b && i < b.length; i++) if (b.start(i) > now + 0.2) return b.start(i);
		return null;
	}

	// while the user is rewound, only our own seeks (and the user's taps on the
	// site's controls) may move the playhead forward. The exception is a real
	// stall: at a hole in the held video the playhead is moved to the next
	// piece; when the held video has truly run out the site may go live.
	function guardSiteSeeks() {
		var v = siteVideo();
		if (!v || v._cbxGuarded || !timeDesc || !timeDesc.set) return;
		v._cbxGuarded = true;
		Object.defineProperty(v, 'currentTime', {
			configurable: true,
			// while the site's player is parked under ours, the clock it reports keeps running:
			// its dead-man timer only wants to see currentTime advance (nothing else reads it then)
			get: function () { var c = v._cbxClock; return c ? c.base + (Date.now() - c.at) / 1000 : timeDesc.get.call(v); },
			set: function (t) {
				if (v._cbxAdopted) { timeDesc.set.call(v, t); return; }
				if (S.siteDvr && !P.ourSeek && Date.now() - siteUiTap > 800) {
					var want = Number(t), now = timeDesc.get.call(v), w = seekWindow(v);
					if (w && now < w.end - 3 && want > now + 2) {
						var stalled = !v.seeking && v.readyState < 3;
						if (!stalled) {
							siteDvr.dropped++;
							// Who pulls to live is worth knowing, a handful of times. A stack used to be captured for every
							// dropped seek (twice when the caller was new), and a site player that wants to go live tries
							// again and again. Six captures name the callers; after that only the counter runs.
							if ((siteDvr.stacks || 0) < 6 || S.debug) {
								siteDvr.stacks = (siteDvr.stacks || 0) + 1;
								var stack = String(new Error().stack || ''), frames = stack.split('\n').slice(2, 4).map(function (l) { return l.trim(); }).join(' < ');
								if (frames !== siteDvr.lastDrop) { siteDvr.lastDrop = frames; log('site seek to ' + want.toFixed(1) + ' dropped while rewound at ' + now.toFixed(1) + '\n' + stack); }
								else if (S.debug && siteDvr.dropped % 10 === 1) log('site seeks dropped: ' + siteDvr.dropped + ' (latest to ' + want.toFixed(1) + ' at ' + now.toFixed(1) + ')');
							}
							return;
						}
						var nx = nextBufferedStart(v, now);
						if (nx != null && nx + 0.1 < want) { siteDvr.skips++; log('starved at a hole; moving to ' + nx.toFixed(1)); t = nx + 0.1; }
					}
				}
				timeDesc.set.call(v, t);
			}
		});
	}

	// The site arms a 10 s dead-man timer that only 'playing', 'waiting', or currentTime
	// advancing 5 s past its last checkpoint will reset. A rewound or paused player never
	// passes that checkpoint, so after 10 s the site rebuilds the player and the held
	// window is gone (twice in a row and it falls back to its JPEG player). While we are
	// behind live or paused, a synthetic 'playing' every 4 s keeps the timer reset; it is
	// what the browser fires after every real seek anyway, and the site's listeners for
	// it are the timer, a once-only metric and a class toggle. At live it is left alone.
	function siteHeartbeat() {
		if (!S.siteDvr) return;
		var v = siteVideo();
		if (!v || !v._cbxGuarded || !v.isConnected) return;
		// parked native player: a timeupdate every tick lets its watchdog read the running clock
		if (v._cbxClock) {
			if (v.paused) { try { v.dispatchEvent(new Event('timeupdate')); siteDvr.beats++; } catch (e) {} }
			return;
		}
		if (v._cbxAdopted) {
			if (!v.paused || v.readyState < 2 || Date.now() - (siteDvr.lastBeat || 0) < 3900) return;
			try { v.dispatchEvent(new Event('playing')); siteDvr.beats++; siteDvr.lastBeat = Date.now(); } catch (e) {}
			return;
		}
		if (siteIsMse() !== true) return;
		if (Date.now() - (siteDvr.lastBeat || 0) < 3900) return;
		var w = seekWindow(v);
		// nothing to keep alive until the stream is actually running; a video that has not
		// started yet is 'paused' too, and a playing event then only confuses the site's metrics
		if (!w || v.readyState < 2 || v.currentTime < 1) return;
		var behind = v.currentTime < w.end - 3;
		if (!behind && !v.paused) return;
		try { v.dispatchEvent(new Event('playing')); siteDvr.beats++; siteDvr.lastBeat = Date.now(); } catch (e) {}
	}
	// the site swapping its MediaSource (a rebuild, a room change) means a fresh hls.js that
	// is still finding its feet: leave its quality menu alone for a while
	function watchSiteRebuild() {
		var v = siteVideo();
		if (!v || v._cbxAdopted) return;
		var src = v.currentSrc || v.src || '';
		if (src === siteDvr.lastSrc) return;
		var room = roomName() || '';
		if (siteDvr.lastSrc && src) {
			if (room === siteDvr.lastRoom) { siteDvr.rebuilds++; log('site player rebuilt (#' + siteDvr.rebuilds + ')'); }
			else log('room changed; site player reloaded');
		}
		siteDvr.lastSrc = src; siteDvr.lastRoom = room;
		siteDvr.quietUntil = Date.now() + 15000;
	}

	// site flavor: is the picture actually on screen? names the first ancestor that hides it
	function sitePictureHidden() {
		var v = P.site && P.site.isConnected ? P.site : siteVideo();
		if (!v) return '';
		var n = v, hops = 0;
		while (n && n !== document.body && hops++ < 14) {
			var cs = getComputedStyle(n);
			var why = cs.display === 'none' ? 'display:none' : cs.visibility === 'hidden' ? 'visibility:hidden' : cs.opacity === '0' ? 'opacity:0' : '';
			if (why) return (n === v ? 'the video itself' : (n.id ? '#' + n.id : n.tagName.toLowerCase() + (typeof n.className === 'string' && n.className.trim() ? '.' + n.className.trim().split(/\s+/).slice(0, 2).join('.') : ''))) + ' (' + why + ')';
			n = n.parentElement;
		}
		var r = v.getBoundingClientRect();
		if (r.width < 10 || r.height < 10) return 'the video has no size (' + Math.round(r.width) + 'x' + Math.round(r.height) + ')';
		return '';
	}

	// the parked native site player: paused, a clock that keeps running, its watchdog fed
	function startSiteClock(site) {
		if (site._cbxClock) return;
		var base = 0; try { base = timeDesc.get.call(site) || 0; } catch (e) {}
		site._cbxClock = { base: base, at: Date.now() };
		log('site player clock started at ' + base.toFixed(1));
	}
	function clearSiteClock() {
		var v = P.site && P.site.isConnected ? P.site : siteVideo();
		if (v && v._cbxClock) { delete v._cbxClock; log('site player clock stopped'); }
	}

	function siteDvrNote() {
		var v = siteVideo();
		if (!v) return 'site player: not on this page';
		var src = v.currentSrc || v.src || '';
		var kind = v._cbxAdopted ? "native HLS, our engine under the site's own video (one player)"
			: /^blob:/.test(src) ? 'MSE' : (src ? 'native HLS (nothing to hook' + (autoDeep() ? ' — ' + (adoptActive() ? 'one player starting' : 'overlay player used instead') : '') + ')' : 'no source yet');
		var r = bufferedRanges(v), w = seekWindow(v);
		var hidden = siteFlavor() ? sitePictureHidden() : '';
		var lines = [
			'site player: ' + kind + (v._cbxGuarded ? ' · seek guard on' : '') + (siteFlavor() ? ' · picture ' + (hidden ? 'HIDDEN by ' + hidden : 'visible') : ''),
			'held: ' + (w ? fmtTime(w.end - w.start) : '—') + (r.length > 1 ? ' in ' + r.length + ' pieces' : '') +
				' · keep ' + fmtTime(keepBack()) + (siteDvr.halvings ? ' (halved ' + siteDvr.halvings + '×)' : ''),
			'trims clamped ' + siteDvr.trims + ' · quota hits ' + siteDvr.quota + ' · site seeks dropped ' + siteDvr.dropped + ' · hole skips ' + siteDvr.skips,
			'heartbeats ' + siteDvr.beats + ' · site rebuilds ' + siteDvr.rebuilds + (Date.now() < siteDvr.quietUntil ? ' · quality changes paused ' + Math.ceil((siteDvr.quietUntil - Date.now()) / 1000) + 's' : '')
		];
		lines.push('engine: ' + engineMode() + (P.adopted ? ' · adopted' + (P.armed ? ', armed' : ', warming up') : '') + (A.blocked ? ' · one player gave up here: ' + (A.why || '?') : '') + (adoptFails() ? ' · failures this tab: ' + adoptFails() : '') + (P.sleeping ? ' · asleep' : ''));
		if (siteDvr.lastDrop) lines.push('last snap from: ' + siteDvr.lastDrop);
		if (!S.siteDvr) lines.unshift('off — hooks pass through');
		return lines.join('\n');
	}
	function refreshSiteDvrNote() {
		if (!panelEl || !panelEl.classList.contains('cbx-on')) return;
		var n = $('#cbx-sitedvr-note', panelEl);
		if (!n) return;
		var txt = siteDvrNote();
		if (n.textContent !== txt) n.textContent = txt;
	}

	/* ================================================================== *
	 * player
	 *
	 * Site structure (measured): video.vjs-tech > #chat-player >
	 * .videoPlayerDiv (absolute, overflow hidden) > #TheaterModePlayer
	 * (relative, overflow hidden) — all the same size. The site's video is
	 * an absolute layer filling that box. Ours is a second absolute layer in
	 * the same box: picture above, controls beneath. The box is found by
	 * walking up from the video while the ancestor has the same size, so
	 * no selector and no rectangle maths are needed.
	 *
	 * Engine: hls.js (loaded by the userscript manager via @require).
	 * ================================================================== */

	function ranges(tr) {
		var out = [];
		if (!tr) return out;
		for (var i = 0; i < tr.length; i++) out.push(tr.start(i).toFixed(1) + '-' + tr.end(i).toFixed(1));
		return out;
	}
	function seekWindow(v) {
		if (!v) return null;
		if (v.buffered && v.buffered.length) return { start: v.buffered.start(0), end: v.buffered.end(v.buffered.length - 1) };
		if (v.seekable && v.seekable.length) return { start: v.seekable.start(0), end: v.seekable.end(v.seekable.length - 1) };
		return null;
	}

	var P = { block: null, shell: null, bar: null, video: null, site: null, box: null, eng: null, busy: false, suspended: false, forcedSite: false, attempt: 0, armed: false, heldNote: '', heldSec: 0, ring: null, ourSeek: false, seekSeq: 0, userPaused: false, sleeping: false, adopted: false, wake: null, fillAuto: false };
	var userHasInteracted = false;
	['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
		document.addEventListener(ev, function () { userHasInteracted = true; }, { capture: true, once: true });
	});
	function isTouch() { return document.documentElement.classList.contains('cbx-touch'); }
	// a phone-sized coarse-pointer screen; touch laptops and DevTools device mode on a wide window do not count
	function isPhone() {
		if (!isTouch()) return false;
		if (innerWidth < 900) return true;
		var coarse = false; try { coarse = matchMedia('(pointer:coarse)').matches; } catch (e) {}
		return coarse && Math.min(innerWidth, innerHeight) < 600;
	}
	function isIOS() { return /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); }
	function wantsTouchLayout() {
		if (!('ontouchstart' in window) && !(navigator.maxTouchPoints > 0)) return false;
		var coarse = false; try { coarse = matchMedia('(pointer:coarse)').matches; } catch (e) {}
		return coarse || innerWidth < 900;
	}
	function applyTouchClass() {
		var want = wantsTouchLayout(), had = isTouch();
		document.documentElement.classList.toggle('cbx-touch', want);
		if (want !== had && P.block) { log('layout changed to', want ? 'touch' : 'desktop', '- rebuilding the player'); removeDvr(); scheduleWork(); }
	}
	// data saver: on by hand, or automatically on cellular / browser save-data / low battery
	var envSaver = false;
	function dataSaverOn() { return !!S.dataSaver || (!!S.dataSaverAuto && envSaver); }
	function watchEnvSaver() {
		var c = navigator.connection, batt = null;
		var eval_ = function () {
			var was = envSaver;
			envSaver = !!(c && (c.saveData || c.type === 'cellular')) || !!(batt && !batt.charging && batt.level <= 0.2);
			if (was !== envSaver) { log('data saver', envSaver ? 'on' : 'off'); if (P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap())); updatePlayerToggle(); }
		};
		if (c && c.addEventListener) c.addEventListener('change', eval_);
		if (navigator.getBattery) navigator.getBattery().then(function (b) { batt = b; ['levelchange', 'chargingchange'].forEach(function (ev) { b.addEventListener(ev, eval_); }); eval_(); }).catch(function () {});
		eval_();
	}
	function dvrBudget() {
		var mb = isPhone() ? 60 : 120;
		if (dataSaverOn()) mb = Math.round(mb / 2);
		return mb * 1024 * 1024;
	}

	// the site keeps a hidden theater-mode copy with the same ids: take the one that has a size
	var siteVideoCache = { at: 0, v: null };
	function siteVideo() {
		if (siteVideoCache.v && siteVideoCache.v.isConnected && Date.now() - siteVideoCache.at < 400) return siteVideoCache.v;
		var out = siteVideoUncached();
		siteVideoCache = { at: Date.now(), v: out };
		return out;
	}
	function siteVideoUncached() {
		var list = $$('video').filter(function (v) {
			return !v.closest('#cbx-hover') && !v.closest('#cbx-watch') && !v.closest('#cbx-block') && !v.classList.contains('cbx-inline-prev');
		});
		var vis = list.filter(function (v) { return v.getBoundingClientRect().width > 0; });
		return vis[0] || list[0] || null;
	}
	function activeVideo() {
		if (P.video && P.video.isConnected && P.video.readyState >= 1) return P.video;
		return P.site && P.site.isConnected ? P.site : siteVideo();
	}
	// the player box: outermost ancestor still the same size as the video
	function playerBox(site) {
		var r = site.getBoundingClientRect(), n = site, box = null;
		for (var i = 0; i < 6 && n.parentElement && n.parentElement !== document.body; i++) {
			var p = n.parentElement, pr = p.getBoundingClientRect();
			if (Math.abs(pr.width - r.width) > 3 || Math.abs(pr.height - r.height) > 3) break;
			box = p; n = p;
		}
		return box || site.parentElement;
	}

	// Apple's own fullscreen player scrubs whatever the element reports as seekable. hls.js reports the
	// playlist's few seconds; with the range widened to everything held, the native scrubber can rewind too.
	function widenSeekable(h, video) {
		var H = requiredHls(); if (!H || !H.Events || !h) return;
		h.on(H.Events.LEVEL_UPDATED, function (_, d) {
			try {
				if (!S.nativeScrub || !d || !d.details || !d.details.live || !video.buffered.length) return;
				var ms = mediaSourceOf(video); if (!ms || ms.readyState !== 'open' || !ms.setLiveSeekableRange) return;
				var start = video.buffered.start(0), fr = d.details.fragments;
				var end = fr && fr.length ? fr[0].start + d.details.totalduration : video.buffered.end(video.buffered.length - 1);
				if (end > start + 1) ms.setLiveSeekableRange(start, end);
			} catch (e) {}
		});
	}

	/* ---- engines ---- */
	function hlsEngine(video, url) {
		var E = { kind: 'hls', h: null };
		E.attach = function (onFatal) {
			return attachStream(video, url, {
				onFatal: onFatal,
				backBufferLength: S.siteKeep, liveDurationInfinity: true, capLevelToPlayerSize: false, lowLatencyMode: false,
				// sit ~4 segments behind live so jitter never drains the forward buffer;
				// drift back toward the edge at 1.05x instead of stalling and reloading
				maxBufferLength: 20, maxMaxBufferLength: 60, liveSyncDurationCount: 4,
				// liveMaxLatencyDurationCount stays at its default (Infinity): with a limit, hls.js seeks a
				// rewound player back to live on the next playlist load, which is exactly the rewind we hold
				maxBufferHole: 1.5, nudgeMaxRetry: 10, startFragPrefetch: true,
				abrEwmaDefaultEstimate: 2500000, startLevel: P.attempt > 0 ? 0 : -1
			}).then(function () {
				E.h = video._cbxHls;
				if (!E.h) throw new Error('hls.js did not attach');
				if (S.clipSave) E.ring();
				if (S.nativeScrub) widenSeekable(E.h, video);
			});
		};
		E.tracks = function () {
			var h = E.h; if (!h || !h.levels) return [];
			return h.levels.map(function (l, i) { return { id: i, height: l.height || 0, fps: levelFps(l), bw: l.bitrate || 0, label: levelLabel(l) }; })
				.sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps) || (b.bw - a.bw); });
		};
		E.active = function () { var h = E.h; if (!h) return null; var i = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel; return E.tracks().filter(function (t) { return t.id === i; })[0] || null; };
		E.isAuto = function () { return !!E.h && E.h.autoLevelEnabled; };
		E.pin = function (t) { var h = E.h; if (!h || !t) return; h.autoLevelCapping = -1; h.loadLevel = t.id; h.nextLevel = t.id; };
		E.auto = function () { var h = E.h; if (h) { h.loadLevel = -1; h.nextLevel = -1; } };
		E.retry = function () { try { E.h.startLoad(); } catch (e) {} };
		E.goLive = function () { var h = E.h, w = seekWindow(video); if (h && h.liveSyncPosition != null) setTime(video, h.liveSyncPosition); else if (w) setTime(video, w.end - 0.5); };
		E.setBehind = function (sec) { if (E.h) E.h.config.backBufferLength = sec; };
		E.destroy = function () { oursDo(function () { try { if (E.h) E.h.destroy(); } catch (e) {} }); E.h = null; if (video._cbxHls) video._cbxHls = null; };
		E.ring = function () {
			var h = E.h, ring = P.ring = { init: null, frags: [], bytes: 0, ext: 'mp4', level: -1 };
			h.on(Hls.Events.FRAG_LOADED, function (_, d) {
				var f = d && d.frag, buf = d && d.payload;
				if (!f || !buf || f.type !== 'main' || !P.ring) return;
				if (f.sn === 'initSegment') { ring.init = buf; ring.level = f.level; return; }
				if (/\.ts(\?|$)/i.test(f.relurl || '')) ring.ext = 'ts';
				if (f.level !== ring.level) { ring.frags = []; ring.bytes = 0; ring.init = null; ring.level = f.level; }
				ring.frags.push({ buf: buf, start: f.start, end: f.start + (f.duration || 0) }); ring.bytes += buf.byteLength;
				while (ring.frags.length && ring.bytes > dvrBudget() / 2) ring.bytes -= ring.frags.shift().buf.byteLength;
			});
		};
		return E;
	}

	// phones: 480p keeps the forward buffer healthy on a 60 MB budget
	function dvrCap() {
		var want = S.qualityCap ? Math.min(S.qualityCap, 720) : 720;
		if (isPhone()) want = Math.min(want, 480);
		if (dataSaverOn()) want = want ? Math.min(want, 360) : 360;
		return want;
	}
	function bestTrackUnder(cap) {
		var list = P.eng ? P.eng.tracks() : [];
		var ok = list.filter(function (t) { return t.height <= (cap || Infinity); });
		return ok[0] || list[list.length - 1] || null;
	}
	function pinTrack(t) {
		if (!P.eng || !t) return;
		P.eng.pin(t);
		fitBufferToMemory(t);
		log('pinned to', t.label, '(no flush)');
	}
	function fitBufferToMemory(t) {
		try {
			var budget = dvrBudget();
			var bps = t && t.bw; if (!bps) return;
			// reserve room for ~20s of forward buffer before sizing the rewind window
			var fwd = 20 * bps / 8;
			var use = Math.max(60, Math.min(S.siteKeep, Math.floor(Math.max(0, budget - fwd) / (bps / 8))));
			P.eng.setBehind(use); P.heldSec = use;
			if (use < S.siteKeep) {
				// which rendition would actually fit the window that was asked for?
				var fits = (P.eng.tracks() || []).filter(function (o) { return o.bw && budget / (o.bw / 8) >= S.siteKeep; })[0];
				P.heldNote = 'holds ~' + fmtTime(use) + ' at ' + (t.label || t.height + 'p') + ' (asked for ' + fmtTime(S.siteKeep) + ')' +
					(fits ? ' — ' + (fits.label || fits.height + 'p') + ' would hold the full ' + fmtTime(S.siteKeep) : ' — no rendition fits that window in memory');
			} else P.heldNote = 'holds up to ' + fmtTime(use) + ' at ' + (t.label || t.height + 'p');
			log('back buffer', use + 's at', Math.round(bps / 1000) + 'kbps');
		} catch (e) {}
	}

	/* ---- transport ---- */
	function seekTo(v, target, quiet) {
		var before = v.currentTime;
		target = snapToBuffered(v, target);
		if (Math.abs(target - before) < 0.3) { if (!quiet) toast(target <= before ? 'Already at the start of the buffer' : 'Already live'); return false; }
		try { setTime(v, target); } catch (e) { toast('This player refused the seek'); return false; }
		var seq = ++P.seekSeq;
		setTimeout(function () {
			if (seq !== P.seekSeq) return; // a newer seek took over; judging this one would be wrong
			var landed = Math.abs(v.currentTime - target) < 2;
			log('seek', before.toFixed(1), '->', target.toFixed(1), '->', v.currentTime.toFixed(1), landed ? 'ok' : 'REFUSED');
			if (!landed && v !== P.video) toast(S.siteDvr ? 'The site player pulled back to live — see the note in the Rewind tab' : 'The site player pulled back to live — deep rewind is off on this room');
		}, 400);
		return true;
	}
	function seekBack(sec) {
		var v = activeVideo(), w = seekWindow(v);
		if (!v || !w) { toast('Nothing held to rewind into'); return; }
		var want = v.currentTime - sec, target = Math.max(w.start + 0.3, want);
		if (!seekTo(v, target)) return;
		if (want < w.start) toast('Start of the buffer (' + fmtTime(w.end - w.start) + ' held)');
		updateBar();
	}
	function seekForward(sec) {
		var v = activeVideo(), w = seekWindow(v);
		if (!v || !w) return;
		var target = Math.min(w.end - 0.5, v.currentTime + sec);
		if (target >= w.end - 1.5) { goLive(); return; }
		seekTo(v, target); updateBar();
	}
	function seekToStart() { var v = activeVideo(), w = seekWindow(v); if (v && w) { seekTo(v, w.start + 0.5); updateBar(); } }
	function goLive() {
		var v = activeVideo(), w = seekWindow(v);
		if (!v || !w) return;
		v.playbackRate = 1; P.userPaused = false;
		if (v === P.video && P.eng) P.eng.goLive(); else seekTo(v, w.end - 0.5);
		var p = v.play(); if (p && p.catch) p.catch(function () {});
		updateBar();
	}
	function togglePause() {
		// asleep (a phone tab that was in the background): the play button wakes the player up
		if (P.sleeping) { P.userPaused = false; wakePlayer(0); return; }
		var v = activeVideo(); if (!v) return;
		if (v.paused) { P.userPaused = false; var p = v.play(); if (p && p.catch) p.catch(function () {}); }
		else { P.userPaused = true; v.pause(); }
		updateBar();
	}
	/* Sound is the viewer's choice and is made here, in the tap. Three things are taken care of:
	 * - with one player the element is also the site's, and the site may write its own idea of "muted"
	 *   back when it hears the change: for a moment after a choice, anything else is put back (and logged);
	 * - a boost or a recording routes the sound through an AudioContext, which a phone suspends on its own
	 *   and only lets a tap resume: every choice resumes it;
	 * - un-muting again shortly after un-muting is somebody hunting for sound that did not come: the decoder
	 *   gets a nudge (a tiny seek), which is what brings a silent MSE audio track back. */
	var muteWant = null, lastUnmuteAt = 0;
	function setMutedOurs(v, to) {
		userHasInteracted = true;
		muteWant = { v: v, muted: to, until: Date.now() + 2500, n: 0 };
		v.muted = to;
		if (!to && v.volume === 0) v.volume = 0.5;
		resumeAudio(true);
		if (!to) {
			var again = Date.now() - lastUnmuteAt < 8000; lastUnmuteAt = Date.now();
			if (again && !v.paused && v._cbxHls) { log('sound: un-muted twice in a row; nudging the decoder'); try { setTime(v, v.currentTime + 0.05); } catch (e) {} }
			var pill = $('#cbx-soundpill'); if (pill) pill.remove();
		}
	}
	function toggleMute() { var v = activeVideo(); if (!v) return; setMutedOurs(v, !v.muted); updateBar(); }
	var lastSoundLog = '';
	document.addEventListener('volumechange', function (e) {
		var v = e.target; if (!(v instanceof HTMLVideoElement) || IS_MULTI || v !== activeVideo()) return;
		var w = muteWant;
		if (w && w.v === v && Date.now() < w.until && v.muted !== w.muted && w.n < 3) {
			w.n++; log('sound: something set muted=' + v.muted + ' right after you chose ' + w.muted + '; put back');
			v.muted = w.muted;
			// a phone may answer a sound change it did not see a tap for by pausing: then sound waits for the pill
			setTimeout(function () { if (v.paused && !P.userPaused && !document.hidden) playOrMuted(v); }, 350);
			return;
		}
		var now = 'muted=' + v.muted + ' volume=' + (Math.round(v.volume * 4) / 4);
		if (now !== lastSoundLog) { lastSoundLog = now; log('sound: ' + now); updateBar(); }
	}, true);
	function setRate(v, r) { try { v.playbackRate = r; } catch (e) {} syncRateButtons(); }
	function toggleCatchUp() { var v = activeVideo(); if (!v) return; setRate(v, v.playbackRate > 1 ? 1 : 2); toast(v.playbackRate > 1 ? 'Catching up at 2×' : 'Normal speed'); }
	function toggleSlow() { var v = activeVideo(); if (!v) return; setRate(v, v.playbackRate < 1 ? 1 : 0.5); toast(v.playbackRate < 1 ? 'Slow motion 0.5×' : 'Normal speed'); }
	function syncRateButtons() {
		var v = activeVideo(), r = v ? v.playbackRate : 1;
		$$('[data-act="fast"]').forEach(function (b) { b.classList.toggle('cbx-on', r > 1); });
		$$('[data-act="slow"]').forEach(function (b) { b.classList.toggle('cbx-on', r < 1); });
		$$('[data-act="loop"]').forEach(function (b) { b.classList.toggle('cbx-on', P.loopA != null); b.title = loopTitle(); });
	}
	// A-B loop over the held buffer: press once for A, again for B, again to clear
	function loopTitle() { return P.loopB != null ? 'Loop set (click to clear)' : P.loopA != null ? 'A set — click to set B' : 'Set loop start (A)'; }
	function cycleLoop() {
		var v = activeVideo(); if (!v) return;
		if (P.loopA == null) { P.loopA = v.currentTime; toast('Loop start set — press again for the end'); }
		else if (P.loopB == null) {
			if (v.currentTime <= P.loopA + 0.5) { toast('Loop end must be after the start'); return; }
			P.loopB = v.currentTime; toast('Looping ' + fmtTime(Math.round(P.loopB - P.loopA)));
			if (!v._cbxLoop) { v._cbxLoop = function () { if (P.loopA != null && P.loopB != null && v.currentTime >= P.loopB) { try { v.currentTime = P.loopA; } catch (e) {} } }; v.addEventListener('timeupdate', v._cbxLoop); }
		} else { clearLoop(); toast('Loop cleared'); }
		syncRateButtons(); updateBar();
	}
	function clearLoop() { P.loopA = P.loopB = null; syncRateButtons(); }

	// bookmarks: B drops one at the current time, [ and ] jump between them
	function addBookmark() {
		var v = activeVideo(), w = seekWindow(v); if (!v || !w) return;
		P.marks = P.marks || [];
		var t = v.currentTime, near = P.marks.filter(function (m) { return Math.abs(m - t) < 1.5; })[0];
		if (near != null) { P.marks = P.marks.filter(function (m) { return m !== near; }); toast('Bookmark removed'); }
		else { P.marks.push(t); P.marks.sort(function (a, b) { return a - b; }); toast('Bookmark ' + P.marks.length + ' at −' + fmtTime(Math.max(0, w.end - t))); }
		updateBar();
	}
	function jumpBookmark(dir) {
		var v = activeVideo(); if (!v || !P.marks || !P.marks.length) { toast('No bookmarks yet — press B to add one'); return; }
		var t = v.currentTime, list = dir > 0 ? P.marks.filter(function (m) { return m > t + 0.8; }) : P.marks.filter(function (m) { return m < t - 0.8; }).reverse();
		if (!list.length) { toast(dir > 0 ? 'No later bookmark' : 'No earlier bookmark'); return; }
		seekTo(v, list[0], true); updateBar();
	}
	function pruneMarks(w) {
		if (P.marks) P.marks = P.marks.filter(function (m) { return m >= w.start; });
		if (P.tips) P.tips = P.tips.filter(function (m) { return m >= w.start; });
	}
	// small frames every 10s for the scrub preview; kept in memory only
	var THUMB_EVERY = 10, THUMB_W = 160, THUMB_COLS = 8;
	// all frames share one sprite-sheet canvas; slots are reused in a ring
	// iOS Safari caps the memory of all canvases of a page together and is slow to take back what is only
	// unreferenced; a canvas set to 0x0 gives its backing store back at once.
	function freeCanvas(c) { try { if (c && c.width) { c.width = 0; c.height = 0; } } catch (e) {} }
	function freeSheet() {
		if (P.sheet) { freeCanvas(P.sheet.c); P.sheet.ctx = null; P.sheet = null; }
		var pv = $('#cbx-thumb-prev canvas');
		if (pv) { freeCanvas(pv); if (pv.parentElement) pv.parentElement._slot = null; }
	}
	function thumbSheet(v) {
		var need = Math.ceil(heldTarget() / THUMB_EVERY) + 2, th = Math.round(THUMB_W * v.videoHeight / v.videoWidth) || 90;
		var sh = P.sheet;
		if (!sh || sh.slots !== need || sh.h !== th) {
			if (sh) freeCanvas(sh.c);
			var c = document.createElement('canvas');
			c.width = THUMB_W * THUMB_COLS; c.height = th * Math.ceil(need / THUMB_COLS);
			sh = P.sheet = { c: c, ctx: c.getContext('2d'), slots: need, h: th, next: 0 };
			P.thumbs = [];
		}
		return sh;
	}
	function captureThumb() {
		var v = dvrVideo();
		if (!S.scrubThumbs || !v || !(P.armed || siteDvrActive()) || v.paused || v.readyState < 2 || !v.videoWidth) return;
		var w = seekWindow(v); if (!w) return;
		P.thumbs = P.thumbs || [];
		var last = P.thumbs[P.thumbs.length - 1];
		if (last && v.currentTime - last.t < THUMB_EVERY - 0.5) return;
		try {
			var sh = thumbSheet(v), slot = sh.next; sh.next = (sh.next + 1) % sh.slots;
			var x = (slot % THUMB_COLS) * THUMB_W, y = Math.floor(slot / THUMB_COLS) * sh.h;
			sh.ctx.drawImage(v, x, y, THUMB_W, sh.h);
			P.thumbs = P.thumbs.filter(function (th) { return th.t >= w.start && th.slot !== slot; });
			P.thumbs.push({ t: v.currentTime, slot: slot });
		} catch (e) { S.scrubThumbs = false; log('thumbs off:', e && e.message); }
	}
	function showThumbAt(scrub, clientX) {
		var v = activeVideo(), w = seekWindow(v);
		var box = $('#cbx-thumb-prev', P.bar);
		if (!box || !v || !w || !S.scrubThumbs || !P.thumbs || !P.thumbs.length || v !== dvrVideo()) return false;
		var r = scrub.getBoundingClientRect(), frac = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width)));
		var t = w.end - scrubWindow(v, w) * (1 - frac);
		if (t < w.start) return false;
		var best = null; P.thumbs.forEach(function (th) { if (!best || Math.abs(th.t - t) < Math.abs(best.t - t)) best = th; });
		if (!best || Math.abs(best.t - t) > THUMB_EVERY) return false;
		var sh = P.sheet, pc = box.firstChild;
		if (sh && box._slot !== best.slot) {
			box._slot = best.slot; pc.width = THUMB_W; pc.height = sh.h;
			pc.getContext('2d').drawImage(sh.c, (best.slot % THUMB_COLS) * THUMB_W, Math.floor(best.slot / THUMB_COLS) * sh.h, THUMB_W, sh.h, 0, 0, THUMB_W, sh.h);
		}
		return true;
	}
	function hideThumb() { var b = $('#cbx-thumb-prev'); if (b) b.style.display = 'none'; }
	// hover on the timeline: a marker on the track, a time caption above it and the nearest thumbnail
	function showHoverAt(scrub, clientX) {
		var v = activeVideo(), w = seekWindow(v);
		if (!v || !w) { hideHover(scrub); return; }
		var r = scrub.getBoundingClientRect(), frac = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width)));
		var win = scrubWindow(v, w), t = w.end - win * (1 - frac), behind = Math.max(0, w.end - t);
		scrub.classList.add('cbx-hovering');
		var mk = $('.cbx-hover', scrub); if (mk) mk.style.left = (frac * 100).toFixed(2) + '%';
		var box = $('#cbx-thumb-prev', P.bar);
		if (!box) { box = el('div', { id: 'cbx-thumb-prev' }); box.appendChild(document.createElement('canvas')); box.appendChild(el('span')); P.bar.appendChild(box); }
		var cap = box.lastChild; cap.textContent = t < w.start ? 'not held' : (behind < liveEps() ? 'live' : '−' + fmtTime(behind));
		var hasThumb = showThumbAt(scrub, clientX);
		box.firstChild.style.display = hasThumb ? 'block' : 'none';
		var barR = P.bar.getBoundingClientRect(), x = clientX - barR.left, half = (hasThumb ? THUMB_W : 48) / 2;
		box.style.left = (barR.width < half * 2 + 8 ? barR.width / 2 : Math.max(half + 4, Math.min(barR.width - half - 4, x))) + 'px';
		box.style.display = 'block';
	}
	function hideHover(scrub) { if (scrub) scrub.classList.remove('cbx-hovering'); hideThumb(); }
	function toggleStats() { S.statsOverlay = !S.statsOverlay; save(); var st = $('#cbx-stats'); if (st && !S.statsOverlay) st.remove(); updateBar(); toast(S.statsOverlay ? 'Stats on' : 'Stats off'); }
	function renderStats(v, w, behind) {
		if (!P.shell) return;
		var st = $('#cbx-stats', P.shell); if (!st) { st = el('div', { id: 'cbx-stats' }); P.shell.appendChild(st); }
		var t = P.eng && P.eng.active(), h = P.eng && P.eng.h, q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null;
		var lat = h && typeof h.latency === 'number' && isFinite(h.latency) ? h.latency : null;
		var lines = [
			v.videoWidth + '×' + v.videoHeight + (t && t.fps ? ' @ ' + Math.round(t.fps) : ''),
			t && t.bw ? Math.round(t.bw / 1000) + ' kbps' + (P.eng.isAuto() ? ' (auto)' : ' (pinned)') : '',
			lat != null ? 'latency ' + lat.toFixed(1) + 's' : (behind ? 'behind ' + fmtTime(Math.round(behind)) : 'live'),
			'ahead ' + Math.round(P.fwdBuf || 0) + 's · held ' + fmtTime(Math.round(w.end - w.start)),
			q ? 'dropped ' + q.droppedVideoFrames + '/' + q.totalVideoFrames : '',
			(P.stalls ? P.stalls + ' stalls' : '') + (dataSaverOn() ? ' · data saver' : '')
		].filter(Boolean);
		var html = lines.join('<br>'); if (st._html !== html) { st._html = html; st.innerHTML = html; }
	}
	function snapshotFrame() {
		var v = activeVideo();
		if (!v || !v.videoWidth) { toast('No picture to capture'); return; }
		try {
			var c = document.createElement('canvas'); c.width = v.videoWidth; c.height = v.videoHeight;
			c.getContext('2d').drawImage(v, 0, 0);
			var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png' });
			a.href = c.toDataURL('image/png'); document.body.appendChild(a); a.click(); a.remove();
			freeCanvas(c);      // a full-size frame is 8MB and more
			toast('Frame saved');
		} catch (e) { toast('Could not capture this frame'); }
	}
	function togglePiP() {
		var v = activeVideo();
		if (!v) { toast('No video on this page'); return; }
		var refused = function () { toast('Picture in picture was refused'); };
		try {
			if (document.pictureInPictureElement) document.exitPictureInPicture();
			else if (v.requestPictureInPicture) { var pr = v.requestPictureInPicture(); if (pr && pr.catch) pr.catch(refused); }      // a refusal arrives through the promise, not as an exception
			// iPhones that do not have the standard call have had WebKit's own for years
			else if (v.webkitSupportsPresentationMode && v.webkitSupportsPresentationMode('picture-in-picture')) v.webkitSetPresentationMode(v.webkitPresentationMode === 'picture-in-picture' ? 'inline' : 'picture-in-picture');
			else toast('No picture in picture here');
		} catch (e) { refused(); }
	}
	/* Fullscreen. Where the browser can put an element fullscreen, that is what the button does. An
	 * iPhone cannot: only a bare <video> goes fullscreen there, in Apple's own player, without our bar,
	 * gestures, zoom or menus. So on an iPhone the button fills the visible screen with the player box
	 * instead (the same layout the rotate-to-fullscreen setting always fell back to), and Apple's
	 * player is one tap away in the gear menu for when the browser's toolbar has to go as well. */
	function canElementFs() {
		var d = document, t = P.box || d.documentElement;
		return !!((d.fullscreenEnabled || d.webkitFullscreenEnabled) && (t.requestFullscreen || t.webkitRequestFullscreen));
	}
	function nativeFsAvailable() { var v = activeVideo(); return !!(v && v.webkitEnterFullscreen); }
	function nativeFullscreen() {
		var v = activeVideo();
		try { if (v && v.webkitEnterFullscreen) v.webkitEnterFullscreen(); else toast('No native fullscreen in this browser'); }
		catch (e) { toast('The browser refused native fullscreen'); }
	}
	function enterFill(auto) {
		var root = document.documentElement;
		if (root.classList.contains('cbx-land') || !P.box || !P.box.isConnected) return;
		P.box.setAttribute('data-cbx-box', '');
		vpUse(true);
		root.classList.add('cbx-land'); P.fillAuto = !!auto;
		if (P.block) P.block._cbxClips = null; if (P.box) P.box._cbxClips = null;
		backPush('fill', function () { exitFill('back'); });
		setTimeout(checkFillGeometry, 350);
		setTimeout(function () { fitBar(); applyTransform(); armIdleHide(); }, 60);
	}
	// how: 'back' (the Back press already took the history entry), 'forget' (the page is navigating or
	// the player is being rebuilt: never touch history then), anything else (our own button: take it out)
	function exitFill(how) {
		var root = document.documentElement;
		if (!root.classList.contains('cbx-land')) return;
		root.classList.remove('cbx-land'); P.fillAuto = false;
		if (P.block) P.block._cbxClips = null; if (P.box) P.box._cbxClips = null;
		vpUse(false);
		unfloatBlock();
		if (how === 'forget') backForget('fill'); else if (how !== 'back') backPop('fill');
		if (ddOpen) closeDD();
		setTimeout(function () { fitBar(); applyTransform(); }, 60);
	}
	// a transformed or filtered ancestor turns position:fixed into "fixed to that ancestor": the box then
	// does not reach the screen edges. Our own player can step out of the page for the duration; the
	// site's video cannot be moved, so there the viewer is told where the other fullscreen is.
	function checkFillGeometry() {
		if (!document.documentElement.classList.contains('cbx-land') || !P.box || !P.block) return;
		var r = P.box.getBoundingClientRect(), b = viewportBox();
		var off = Math.abs(r.left - b.x) > 8 || Math.abs(r.top - b.y) > 8 || Math.abs(r.width - b.w) > 8 || Math.abs(r.height - b.h) > 8;
		log('fill: box ' + Math.round(r.width) + 'x' + Math.round(r.height) + '@' + Math.round(r.left) + ',' + Math.round(r.top) + ' screen ' + Math.round(b.w) + 'x' + Math.round(b.h) + '@' + Math.round(b.x) + ',' + Math.round(b.y) + (off ? ' - NOT covering' : ' - ok'));
		if (!off) return;
		if (P.video && !P.adopted && P.block.contains(P.video)) { floatBlock(); return; }
		toast('This page layout keeps the player from filling the screen — try Native fullscreen in the gear menu', 6000);
	}
	function floatBlock() {
		var b = P.block; if (!b || b.classList.contains('cbx-float')) return;
		var v = P.video, playing = v && !v.paused;
		b.classList.add('cbx-float'); document.body.appendChild(b);
		if (playing) { var p = v.play(); if (p && p.catch) p.catch(function () {}); }
		log('fill: block moved out of the page');
	}
	function unfloatBlock() {
		var b = P.block; if (!b || !b.classList.contains('cbx-float')) return;
		var v = P.video, playing = v && !v.paused;
		b.classList.remove('cbx-float');
		if (P.box && P.box.isConnected) P.box.appendChild(b); else { removeDvr(); return; }
		if (playing) { var p = v.play(); if (p && p.catch) p.catch(function () {}); }
	}
	function goFullscreen() {
		var target = P.box || (activeVideo() && activeVideo().closest(MAIN_PLAYER_SEL)) || activeVideo();
		if (!target) return;
		try {
			if (document.fullscreenElement || document.webkitFullscreenElement) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
			if (document.documentElement.classList.contains('cbx-land')) { exitFill(false); return; }
			if (canElementFs() && target.tagName !== 'VIDEO') {
				var pr = (target.requestFullscreen || target.webkitRequestFullscreen).call(target);
				if (pr && pr.then) pr.then(function () {
					if (isPhone() && S.rotateFullscreen && screen.orientation && screen.orientation.lock) screen.orientation.lock('landscape').catch(function () {});
				}, function () { enterFill(false); });
				return;
			}
			if (P.box && P.block) { enterFill(false); return; }
			nativeFullscreen();
		} catch (e) { if (P.box && P.block) enterFill(false); }
	}
	document.addEventListener('fullscreenchange', function () { if (!document.fullscreenElement) { try { if (screen.orientation && screen.orientation.unlock) screen.orientation.unlock(); } catch (e) {} P.autoFs = false; } });
	function saveClip() {
		var ring = P.ring;
		if (!ring) { toast('Turn on "Instant clip of the rewind window" in the Record menu under the player, then reload the room', 5000); return; }
		if (!ring.frags.length) { toast('Nothing held yet'); return; }
		var parts = [], frags = ring.frags, ranged = P.loopA != null && P.loopB != null;
		if (ranged) {
			frags = frags.filter(function (f) { return f.end > P.loopA && f.start < P.loopB; });
			if (!frags.length) { toast('The loop range is not in the saved buffer'); return; }
		}
		if (ring.ext === 'mp4') { if (!ring.init) { toast('No init segment captured yet; wait a moment'); return; } parts.push(ring.init); }
		parts = parts.concat(frags.map(function (f) { return f.buf; }));
		var blob = new Blob(parts, { type: ring.ext === 'mp4' ? 'video/mp4' : 'video/mp2t' });
		var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ring.ext });
		a.href = URL.createObjectURL(blob); document.body.appendChild(a); a.click(); a.remove();
		setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
		toast('Saved ' + Math.round(blob.size / 1048576) + ' MB' + (ranged ? ' (loop range, cut on segment edges)' : ''));
	}

	/* ---- block + bar ---- */
	var I = {
		gear: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>',
		back: '<svg viewBox="0 0 24 24"><path d="M12 5V2L7 6l5 4V7a5.5 5.5 0 1 1-5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
		fwd: '<svg viewBox="0 0 24 24"><path d="M12 5V2l5 4-5 4V7a5.5 5.5 0 1 0 5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
		play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>',
		pause: '<svg viewBox="0 0 24 24"><path d="M7 5h4v14H7zM13 5h4v14h-4z" fill="currentColor"/></svg>',
		vol: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9z" fill="currentColor"/><path d="M16 8.5a5 5 0 0 1 0 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
		muted: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',
		cam: '<svg viewBox="0 0 24 24"><path d="M4 8h3l2-2h6l2 2h3v11H4z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>',
		save: '<svg viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		pip: '<svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="11" y="11" width="8" height="6" rx="1" fill="currentColor"/></svg>',
		fs: '<svg viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		prev: '<svg viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6M7 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		next: '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6M17 6v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		flag: '<svg viewBox="0 0 24 24"><path d="M6 21V4h10l-2 4 2 4H6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
		loop: '<svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3M18 3v4h-4M6 21v-4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>'
	};
	function barHTML() {
		return '<div class="cbx-row cbx-row-scrub">' +
			'<div id="cbx-scrub" role="slider" title="Drag to rewind. Right edge is live."><div class="cbx-held"></div><div class="cbx-fill"></div><i class="cbx-hover"></i><div class="cbx-thumb"></div><span id="cbx-scrub-at" class="cbx-time">live</span></div></div>' +
			'<div class="cbx-row cbx-row-btns">' +
			'<button data-act="pause" aria-label="Play or pause">' + I.play + '</button>' +
			'<button data-act="back" title="Back 10s (←)">' + I.back + '<b>10</b></button>' +
			'<button data-act="fwd" title="Forward 10s (→)">' + I.fwd + '<b>10</b></button>' +
			'<span class="cbx-volwrap"><button data-act="mute" aria-label="Mute or unmute">' + I.vol + '</button>' +
			'<div id="cbx-vol" role="slider" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"><div class="cbx-vfill"></div><div class="cbx-vthumb"></div></div></span>' +
			'<button data-act="live" class="cbx-live" title="Go to live (End)"><i></i>LIVE</button>' +
			'<span id="cbx-behind"></span>' +
			'<span class="cbx-grow"></span>' +
			'<button data-act="snap" title="Save a frame (S)">' + I.cam + '</button>' +
			'<button data-act="rec" id="cbx-rec" title="Record (R)"><span id="cbx-rec-dot"></span><span id="cbx-rec-txt"></span></button>' +
			'<button data-act="quality" class="cbx-qbtn" title="Quality (Q)" aria-haspopup="true"><span>…</span></button>' +
			'<button data-act="gear" title="Settings and tools" aria-haspopup="true">' + I.gear + '</button>' +
			'<button data-act="fs" title="Fullscreen">' + I.fs + '</button></div>' +
			'<div id="cbx-recbadge" hidden>● <span></span></div>';
	}

	function ensureBlock() {
		if (P.block && P.block.isConnected) return P.block;
		if (!roomName() || !(wantDeep() || siteDvrActive()) || P.forcedSite) return null;
		var site = siteVideo();
		if (!site || site.getBoundingClientRect().width < 60) return null;
		P.site = site; P.box = playerBox(site);
		var block = el('div', { id: 'cbx-block' }), shell = el('div', { id: 'cbx-shell' }), bar = el('div', { id: 'cbx-bar' }, barHTML());
		block.appendChild(shell); block.appendChild(bar);
		// One layout everywhere: the block overlays the site's own player box and never resizes it.
		// The picture (the site's video, or ours in the shell) fills the box exactly as the site laid
		// it out, and the bar floats over the bottom and fades when idle. On phones the site owns the
		// box height and rewrites it on its own layout passes; growing it only ever ended in a fight.
		// three flavours: the site's own MSE player with our hooks (cbx-site), the site's own <video> with
		// our engine under it (cbx-adopt), or our second player in the shell (neither class)
		var siteMode = siteDvrActive(), adoptMode = !siteMode && adoptActive();
		block.classList.add('cbx-overlay');
		if (siteMode) block.classList.add('cbx-site');
		if (adoptMode) block.classList.add('cbx-adopt');
		if (getComputedStyle(P.box).position === 'static') P.box.style.position = 'relative';
		P.box.setAttribute('data-cbx-box', '');
		P.box.appendChild(block);
		P.block = block; P.shell = shell; P.bar = bar;
		// the slim bar leaves the site's controls alone; the full bar replaces them
		document.documentElement.classList.add('cep-block');
		ensureStripCSS();
		wireBar(bar, shell);
		tagSiteControls();
		fitBar();
		if (window.ResizeObserver) {
			try { P.ro = new ResizeObserver(function () { if (P.block === block) { fitBar(); if (ddOpen) placeDD(); } }); P.ro.observe(block); } catch (e) {}
		}
		if (!P.smoothRaf) P.smoothRaf = requestAnimationFrame(smoothLoop);
		// zoom: a dimmed vertical slider on the left edge; fades with the controls
		var zs = el('div', { id: 'cbx-zoomside', title: 'Zoom — double-click or double-tap the slider to reset' },
			'<button type="button" id="cbx-zoom-val" title="Fit · Fill · 1.5× · 2×">Fit</button><span class="cbx-zwrap"><i class="cbx-zfill"></i><i class="cbx-zknob"></i><input type="range" id="cbx-zoom" min="50" max="300" step="5" value="100" aria-label="Zoom"></span>');
		block.appendChild(zs);
		// a large play button in the middle while paused (also the tap target when autoplay was blocked)
		var big = el('button', { id: 'cbx-bigplay', type: 'button', 'aria-label': 'Play' }, I.play);
		block.appendChild(big);
		['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { big.addEventListener(ev, function (e) { e.stopPropagation(); }); });
		big.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); togglePause(); });
		['mousedown', 'touchstart', 'click'].forEach(function (ev) { zs.addEventListener(ev, function (e) { e.stopPropagation(); }); });
		var zin = $('#cbx-zoom', zs), zTapAt = 0, zTapY = 0, zResetAt = 0;
		// reset to Fit and make the slider agree: syncBar leaves a focused slider alone, and on a
		// phone the tap that triggered this also moved the thumb, so the value is written here
		var zoomReset = function () {
			zResetAt = Date.now();
			setEffZoom(1); rememberZoom();
			zin.value = Math.round(effZoom() * 100); try { zin.blur(); } catch (e) {}
			toast('Zoom reset');
		};
		var zoomSettled = function (e) { if (Date.now() - zResetAt < 500) { e.target.value = Math.round(effZoom() * 100); return false; } return true; };
		zin.addEventListener('input', function (e) { if (zoomSettled(e)) setEffZoom(parseInt(e.target.value, 10) / 100, true); });
		zin.addEventListener('change', function (e) { endLive(); if (zoomSettled(e)) rememberZoom(); });
		['pointerup', 'pointercancel', 'blur'].forEach(function (ev) { zin.addEventListener(ev, function () { endLive(); }); });
		if (xform.zoom === 1) xform.zoom = roomZoom();
		// mouse: double-click anywhere on the zoom column. Touch: two quick taps on the slider (a
		// range input does not raise dblclick reliably on phones, it just moves the thumb twice)
		zs.addEventListener('dblclick', function (e) { e.preventDefault(); if (Date.now() - zResetAt > 500) zoomReset(); });
		zs.addEventListener('pointerdown', function (e) {
			e.stopPropagation();
			if (e.pointerType === 'mouse' || e.target.closest('button')) return;
			var now = Date.now();
			if (now - zTapAt < 350 && Math.abs(e.clientY - zTapY) < 40) { zTapAt = 0; zoomReset(); return; }
			zTapAt = now; zTapY = e.clientY;
		});
		$('#cbx-zoom-val', zs).addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); cycleZoom(); });
		wirePan(shell);
		wireOverlay();
		installPhoneLayout();
		if (siteMode) setTimeout(function () { if (P.block === block && block.isConnected) { var h = sitePictureHidden(); if (h) { log('site picture hidden by ' + h); toast('The site picture is hidden by ' + h + ' — see the Rewind tab note', 6000); } } }, 2000);
		var r = block.getBoundingClientRect();
		log('block mounted in', (P.box.id ? '#' + P.box.id : P.box.className), Math.round(r.width) + 'x' + Math.round(r.height), siteMode ? '(site player)' : adoptMode ? '(one player)' : '(overlay player)');
		return block;
	}
	// site flavor: the bar floats over the picture and fades when the pointer rests on
	// the picture or leaves the player; any activity brings it back, and it stays while
	// paused, while scrubbing, while the quality menu is open or the pointer is on it
	/* The box is the site's and outlives our block; the block is rebuilt (on a phone, every time a tab
	 * comes back after a while). Four listeners were added to the box per block and never taken off, each
	 * one holding its old block, with the whole bar in it, alive. Presses and moves inside the player reach
	 * armIdleHide() through the document listener anyway; only "the pointer left the player" is not visible
	 * from there. That one is added once per box and looks at whatever block is current. */
	function wireOverlay() {
		var box = P.box;
		if (box && !box._cbxLeaveWired) {
			box._cbxLeaveWired = true;
			box.addEventListener('pointerleave', function () { if (P.block && P.box === box) armIdleHide(800); });
		}
		armIdleHide();
	}
	function videoAspect() {
		var v = P.video && P.video.videoWidth ? P.video : (P.site && P.site.videoWidth ? P.site : null);
		return v ? v.videoHeight / v.videoWidth : 9 / 16;
	}
	// phones: swipe across the picture to seek, press and hold for 2x (vertical drags still scroll),
	// drag a zoomed picture to move it (with a little momentum), two fingers to zoom around the fingers
	function wirePan(shell) {
		var x0 = 0, y0 = 0, t0 = 0, lx = 0, ly = 0, mode = null, hold = null, held2x = false, trail = [], pinch = null;
		var SWIPE_SEC = 90; // a full-width swipe moves this many seconds
		var zoomed = panActive;
		var endHold = function () { clearTimeout(hold); if (held2x) { var v = activeVideo(); if (v) setRate(v, 1); held2x = false; } };
		shell.addEventListener('pointerdown', function (e) {
			if (pinch || e.isPrimary === false) return;
			if (e.pointerType === 'mouse' && (e.button !== 0 || !zoomed())) return;
			stopGlide();
			x0 = lx = e.clientX; y0 = ly = e.clientY; mode = 'wait'; held2x = false; trail = [];
			var v = activeVideo(); t0 = v ? v.currentTime : 0;
			clearTimeout(hold);
			if (zoomed()) { try { shell.setPointerCapture(e.pointerId); } catch (err) {} return; }
			if (S.holdFast) hold = setTimeout(function () {
				if (mode !== 'wait') return;
				var vv = activeVideo(); if (!vv || vv.paused) return;
				mode = 'hold'; held2x = true; setRate(vv, 2); toast('2× while held');
			}, 450);
		});
		shell.addEventListener('pointermove', function (e) {
			if (pinch || !mode || mode === 'hold') return;
			var dx = e.clientX - x0, dy = e.clientY - y0;
			if (mode === 'wait') {
				if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
				clearTimeout(hold);
				if (zoomed()) {
					if (P.block && P.block.classList.contains('cbx-panx') && Math.abs(dy) > Math.abs(dx)) { mode = 'scroll'; return; }
					mode = 'pan'; beginLive(); lx = e.clientX; ly = e.clientY;
				}
				else if (e.pointerType === 'mouse') { mode = null; return; }
				else if (Math.abs(dy) > Math.abs(dx)) { mode = 'scroll'; return; }
				else mode = S.swipeSeek ? 'seek' : 'scroll';
			}
			if (mode === 'pan') {
				// step by step from the clamped position: at an edge the picture follows the finger back
				// at once, instead of first "unwinding" the distance it was pushed past the edge
				xform.panX = (xform.panX || 0) + (e.clientX - lx); xform.panY = (xform.panY || 0) + (e.clientY - ly);
				lx = e.clientX; ly = e.clientY;
				clampPan(xfLive);
				var now = performance.now();
				trail.push({ t: now, x: e.clientX, y: e.clientY });
				while (trail.length > 2 && now - trail[0].t > 100) trail.shift();
				queueLive();
				return;
			}
			if (mode === 'seek') {
				var v = activeVideo(), w = seekWindow(v); if (!v || !w) return;
				var t = Math.max(w.start + 0.3, Math.min(w.end - 0.5, t0 + dx / Math.max(1, shell.clientWidth) * SWIPE_SEC));
				try { setTime(v, t); } catch (err) {}
				var scrub = $('#cbx-scrub', P.bar); if (scrub) scrub._pos = 1 - (w.end - t) / scrubWindow(v, w);
				updateBar(true);
			}
		});
		['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { shell.addEventListener(ev, function (e) {
			// touch fires pointerleave right after pointerup: a second pass here with mode already
			// cleared used to reset the swallow flag, and the click that followed a drag or a hold
			// then toggled pause
			if (pinch || !mode) return;
			// a captured pointer that merely crosses the edge of the box is still dragging
			if (ev === 'pointerleave' && mode === 'pan' && shell.hasPointerCapture && shell.hasPointerCapture(e.pointerId)) return;
			endHold();
			if (mode === 'pan') {
				var now = performance.now(), a = trail[0], b = trail[trail.length - 1], vx = 0, vy = 0;
				if (ev === 'pointerup' && a && b && b !== a && now - b.t < 80 && b.t - a.t > 8) { vx = (b.x - a.x) / (b.t - a.t); vy = (b.y - a.y) / (b.t - a.t); }
				if (Math.abs(vx) > 0.15 || Math.abs(vy) > 0.15) glide(Math.max(-3, Math.min(3, vx)), Math.max(-3, Math.min(3, vy))); else endLive();
			} else if (xfLive) endLive();     // a tap that only stopped a glide
			// a timestamp, not a flag: a drag usually produces no click at all, and a plain flag
			// would then swallow the next unrelated tap
			shell._swallow = mode === 'seek' || mode === 'hold' || mode === 'pan' ? Date.now() : 0;
			mode = null; trail = [];
		}); });

		/* two fingers: zoom around the point between them, and move with them. Touch events rather
		 * than pointer pairs, because preventDefault on the second finger's touchstart is what keeps
		 * the browser from scrolling or zooming the page under a box that still allows pan-y. */
		var spread = function (ts) { return Math.max(1, Math.hypot(ts[0].clientX - ts[1].clientX, ts[0].clientY - ts[1].clientY)); };
		var mid = function (ts, r) { return { x: (ts[0].clientX + ts[1].clientX) / 2 - (r.left + r.width / 2), y: (ts[0].clientY + ts[1].clientY) / 2 - (r.top + r.height / 2) }; };
		shell.addEventListener('touchstart', function (e) {
			if (e.touches.length !== 2) return;
			e.preventDefault();
			endHold(); mode = null; trail = [];
			beginLive();
			var g = xfLive; if (!g) return;
			var r = shell.getBoundingClientRect();
			pinch = { r: r, d0: spread(e.touches), f0: mid(e.touches, r), s0: xform.zoom * g.base, px0: xform.panX || 0, py0: xform.panY || 0, z0: xform.zoom };
		}, { passive: false });
		shell.addEventListener('touchmove', function (e) {
			if (!pinch || e.touches.length < 2) return;
			e.preventDefault();
			var g = xfLive; if (!g) return;
			var sc = Math.max(0.5 * g.fit, Math.min(3 * g.fit, pinch.s0 * spread(e.touches) / pinch.d0)), k = sc / pinch.s0, f = mid(e.touches, pinch.r);
			xform.zoom = sc / g.base;
			// the point of the picture that was between the fingers stays between the fingers
			xform.panX = f.x - k * (pinch.f0.x - pinch.px0); xform.panY = f.y - k * (pinch.f0.y - pinch.py0);
			clampPan(g);
			queueLive();
		}, { passive: false });
		var endPinch = function (e) {
			if (!pinch || e.touches.length >= 2) return;
			var changed = Math.abs(xform.zoom - pinch.z0) > 0.005;
			pinch = null;
			// land exactly on Fit or Fill when the fingers stopped close to it
			var z = effZoom(), c = coverScale();
			if (Math.abs(z - 1) < 0.07) xform.zoom = 1 / coverFactor();
			else if (c > 1.03 && Math.abs(z - c) < 0.06) xform.zoom = c / coverFactor();
			clampPan(xfLive);
			shell._swallow = Date.now();
			if (changed) rememberZoom();
			var left = e.touches[0];
			if (left && panActive()) { mode = 'pan'; lx = left.clientX; ly = left.clientY; trail = []; queueLive(); }   // the finger that stayed carries on dragging
			else { mode = null; endLive(); }
		};
		shell.addEventListener('touchend', endPinch);
		shell.addEventListener('touchcancel', endPinch);
		// Safari's own page pinch
		shell.addEventListener('gesturestart', function (e) { e.preventDefault(); });
	}
	/* The site's player draws its own controls (page elements, with a timeline of their own on some
	 * layouts). With our bar on the same picture that made two sets of buttons on top of each other and
	 * two things to drag. Hiding them by class name only ever covered the desktop layout, so they are
	 * found by structure instead: anything inside the player box that holds a button, a slider or a
	 * range input, does not hold the picture, and is not a block of text (a notice with a button in it
	 * is a message, not a control bar). The outermost such container is tagged, so a bar goes together
	 * with its background. Hidden means transparent and untouchable, never display:none: the quality
	 * menu is still driven through these elements. */
	var SITE_CTL_SEL = 'button,[role="button"],[role="slider"],input[type="range"],select,[class*="control" i],[class*="scrub" i],[class*="seekbar" i],[class*="progress" i]';
	function siteControlNodes() {
		var box = P.box, v = P.site && P.site.isConnected ? P.site : siteVideo(), out = [];
		if (!box || !box.isConnected || !v) return out;
		var list; try { list = $$(SITE_CTL_SEL, box); } catch (e) { list = $$('button,[role="button"],[role="slider"],input[type="range"],select', box); }
		list.forEach(function (n) {
			if (n === v || n.contains(v) || n.closest('#cbx-block')) return;
			var top = n;
			while (top.parentElement && top.parentElement !== box && !top.parentElement.contains(v)) top = top.parentElement;
			if (top === box || top.contains(v) || out.indexOf(top) !== -1) return;
			if (!top.querySelector('button,[role="button"],[role="slider"],input[type="range"],select') && !top.matches('button,[role="button"],[role="slider"],input[type="range"],select')) return;
			if ((top.textContent || '').replace(/\s+/g, ' ').trim().length > 120) return;
			out.push(top);
		});
		return out;
	}
	function tagSiteControls() {
		var want = !!P.block && document.documentElement.classList.contains('cep-block');
		var now = want ? siteControlNodes() : [];
		$$('[data-cbx-sitectl]').forEach(function (n) { if (now.indexOf(n) === -1) n.removeAttribute('data-cbx-sitectl'); });
		var fresh = [];
		now.forEach(function (n) { if (!n.hasAttribute('data-cbx-sitectl')) { n.setAttribute('data-cbx-sitectl', ''); fresh.push(n); } });
		if (fresh.length) log('site controls hidden: ' + fresh.map(function (n) {
			var r = n.getBoundingClientRect(), cls = typeof n.className === 'string' ? n.className.trim().split(/\s+/).slice(0, 3).join('.') : '';
			return n.tagName.toLowerCase() + (n.id ? '#' + n.id : '') + (cls ? '.' + cls : '') + ' ' + Math.round(r.width) + 'x' + Math.round(r.height) + '@' + Math.round(r.left) + ',' + Math.round(r.top);
		}).join(' | '));
	}
	var BAR_TIERS = ['cbx-t-full', 'cbx-t-compact'];
	// first to go first. Everything here is also in the gear menu; record and the essentials are not in this list.
	var BAR_DROP = [['held time', '#cbx-behind'], ['snapshot', 'button[data-act="snap"]'], ['quality', '.cbx-qbtn'], ['10s buttons', 'button[data-act="back"],button[data-act="fwd"]']];
	function setBarTier(bar, name) {
		BAR_TIERS.forEach(function (c) { bar.classList.toggle(c, c === name); });
		bar._tier = name;
	}
	var OWN_AT_POINT = '#cbx-block,#cbx-hgrip,#cbx-toast,#cbx-dd,#cbx-launcher,#cbx-dock,#cbx-multidock,#cbx-panel,#cbx-scrim,#cbx-soundpill';
	function coveredBelow(b, r, il, ir) {
		if (!document.elementsFromPoint || !P.box) return 0;
		if (P.hgrip && P.hgrip.classList.contains('cbx-dragging')) return b._covered || 0;      // a dozen hit-tests per frame of a drag, for an answer that cannot change meanwhile
		var W = document.documentElement.clientWidth || innerWidth, VH = window.innerHeight;
		// the bottom edge has to be on screen to be asked about; otherwise the last answer stands
		if (r.bottom > VH + 1 || r.bottom < 40) return b._covered || 0;
		var cx = Math.round(Math.min(W - 8, Math.max(8, r.left + il + (r.width - il - ir) / 2)));
		var covered = 0, top = r.top + r.height * 0.5, hitFree = false;
		for (var y = r.bottom - 5; y > top; y -= 9) {
			if (y < 0) break;
			var els = document.elementsFromPoint(cx, y), first = null;
			for (var i = 0; i < els.length; i++) { if (!els[i].closest || !els[i].closest(OWN_AT_POINT)) { first = els[i]; break; } }
			// the picture (or the page behind it): free. The edge of what covers lies between this look and the
			// one before; take this one, a few pixels too high rather than too low
			if (!first || P.box.contains(first) || first.contains(P.box)) { hitFree = true; if (covered) covered = Math.round(r.bottom - y); break; }
			covered = Math.round(r.bottom - y + 5);
		}
		if (!hitFree) covered = 0;      // covered all the way up: a dialog over the whole page, not a layout to adapt to
		if (covered !== (b._covered || 0)) log('player: ' + covered + 'px of its lower part lies under something else' + (covered ? ' — the bar is lifted above it' : ''));
		b._covered = covered;
		return covered;
	}
	// how much of the block is cut off on each side; handed to the stylesheet as four variables
	function fitInsets() {
		var b = P.block; if (!b || !b.isConnected) return null;
		var vr = visibleRect(b, false), r = vr.full; if (!r.width || !r.height) return null;
		var il = Math.round(Math.max(0, vr.left - r.left)), ir = Math.round(Math.max(0, r.right - vr.right));
		var it = Math.round(Math.max(0, vr.top - r.top)), ib = Math.round(Math.max(0, r.bottom - vr.bottom));
		// The tabs-and-chat panel is laid over whatever hangs below the height handle. The site re-lays its
		// inner player out on its own (coming back to a tab, a stream that restarts) and can leave it taller
		// than the container we hold: the bar then sat on top of the tabs. The block ends at the handle.
		if (P.hgrip && P.hgrip.isConnected && P.hgrip.style.display !== 'none' && document.documentElement.classList.contains('cbx-ph')) {
			var gt = P.hgrip.getBoundingClientRect().top;
			if (gt > r.top + 90 && gt < r.bottom) ib = Math.max(ib, Math.round(r.bottom - gt));
		}
		// and the direct question, which needs no knowledge of how the page is built: is something that is not
		// the player lying over the lower part of the player? (2.1.5 only asked where the handle was. When the
		// site re-renders its tabs-and-chat panel, the panel loses our mark and jumps back up over a player we
		// hold taller; the handle, and so that answer, did not move.)
		ib = Math.max(ib, coveredBelow(b, r, il, ir));
		// scrolled or clipped away almost entirely: that is not a layout to adapt to
		if (vr.width < 160 || vr.height < 90) { il = ir = it = ib = 0; }
		var key = il + ',' + ir + ',' + it + ',' + ib;
		if (b._insets !== key) {
			b._insets = key; b._ins = { il: il, ir: ir, it: it, ib: ib };
			[['--cbx-il', il], ['--cbx-ir', ir], ['--cbx-it', it], ['--cbx-ib', ib]].forEach(function (p) { if (p[1]) b.style.setProperty(p[0], p[1] + 'px'); else b.style.removeProperty(p[0]); });
			log('block ' + Math.round(r.width) + 'x' + Math.round(r.height) + ', visible ' + Math.round(r.width - il - ir) + 'x' + Math.round(r.height - it - ib) + ' (cut off: left ' + il + ', right ' + ir + ', top ' + it + ', bottom ' + ib + ')');
		}
		return { il: il, ir: ir, it: it, ib: ib, width: Math.round(r.width - il - ir) };
	}
	// pick the first tier in which the whole button row fits the visible part of the block
	function fitBar(force) {
		var bar = P.bar, b = P.block; if (!bar || !b || !bar.isConnected) return;
		var ins = fitInsets();
		var w = ins ? ins.width : b.clientWidth; if (!w) return;
		if (!force && bar._fitW === w && bar._fitDone) return;
		bar._fitW = w; bar._fitDone = true;
		var row = $('.cbx-row-btns', bar);
		setBarTier(bar, w >= 640 ? 'cbx-t-full' : 'cbx-t-compact');
		$$('.cbx-drop', bar).forEach(function (n) { n.classList.remove('cbx-drop'); });
		var dropped = [];
		// compact already leaves the held-time text out by stylesheet
		for (var i = bar._tier === 'cbx-t-compact' ? 1 : 0; i < BAR_DROP.length && row && row.scrollWidth > row.clientWidth + 1; i++) {
			$$(BAR_DROP[i][1], bar).forEach(function (n) { n.classList.add('cbx-drop'); });
			dropped.push(BAR_DROP[i][0]);
		}
		var sig = w + '|' + bar._tier + '|' + dropped.join(',');
		if (bar._fitSig !== sig) { bar._fitSig = sig; log('bar: ' + w + 'px visible -> ' + bar._tier.slice(6) + (dropped.length ? ', moved into the gear menu: ' + dropped.join(', ') : ', everything fits')); }
	}
	function removeBlock() {
		if (P.ro) { try { P.ro.disconnect(); } catch (e) {} P.ro = null; }
		if (document.documentElement.classList.contains('cbx-land')) exitFill('forget');
		var oldPoster = $('#cbx-poster'); if (oldPoster) { freeCanvas(oldPoster); oldPoster.remove(); }
		if (P.block) {
			// our panel may have been re-homed into the block for fullscreen; get it out first
			[scrimEl, panelEl, launcherEl, dockEl, ddEl, $('#cbx-toast')].forEach(function (n) { if (n && P.block.contains(n)) document.body.appendChild(n); });
			try { P.block.remove(); } catch (e) {}
		}
		if (P.smoothRaf) { cancelAnimationFrame(P.smoothRaf); P.smoothRaf = null; }
		removePhoneLayout();
		P.block = P.shell = P.bar = null; P.edge = null;
		document.documentElement.classList.remove('cep-block');
		$$('[data-cbx-sitectl]').forEach(function (n) { n.removeAttribute('data-cbx-sitectl'); });
	}
	function wireBar(bar, shell) {
		// keep the site from seeing presses on our bar, but only after our own controls have handled them:
		// a capture-phase stop here swallowed the scrubber's pointerdown for a year
		['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { bar.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: true }); });
		bar.addEventListener('click', function (e) {
			var b = e.target.closest('button'); if (!b) return;
			e.preventDefault(); e.stopPropagation();
			runAct(b.getAttribute('data-act'));
		}, true);
		var scrub = $('#cbx-scrub', bar);
		var seekAt = function (clientX) {
			var v = activeVideo(), w = seekWindow(v); if (!v || !w) return;
			var r = scrub.getBoundingClientRect(), frac = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width)));
			var win = scrubWindow(v, w), t = w.end - win * (1 - frac);
			if (t < w.start) t = w.start + 0.3;
			seekTo(v, Math.min(t, w.end - 0.5), true);
			scrub._pos = 1 - (w.end - t) / win;
			updateBar(true);
		};
		scrub.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); scrub._held = true; try { scrub.setPointerCapture(e.pointerId); } catch (err) {} seekAt(e.clientX); showHoverAt(scrub, e.clientX); });
		scrub.addEventListener('pointermove', function (e) { if (scrub._held) seekAt(e.clientX); if (scrub._held || e.pointerType === 'mouse') showHoverAt(scrub, e.clientX); });
		['pointerup', 'pointercancel'].forEach(function (ev) { scrub.addEventListener(ev, function (e) { scrub._held = false; if (e.pointerType !== 'mouse') hideHover(scrub); }); });
		scrub.addEventListener('pointerleave', function () { if (!scrub._held) hideHover(scrub); });
		var vol = $('#cbx-vol', bar);
		// no hover on touch screens: a tap on the mute button also shows the slider for a moment
		var volwrap = $('.cbx-volwrap', bar);
		if (volwrap) volwrap.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') return; volwrap.classList.add('cbx-open'); clearTimeout(volwrap._t); volwrap._t = setTimeout(function () { volwrap.classList.remove('cbx-open'); }, 3000); });
		var volAt = function (clientX) {
			var r = vol.getBoundingClientRect(), f = Math.min(1, Math.max(0, (clientX - r.left) / Math.max(1, r.width)));
			var v = activeVideo(); if (!v) return;
			userHasInteracted = true; v.volume = f; if (v.muted !== (f === 0)) setMutedOurs(v, f === 0); paintVol(vol, f);
		};
		vol.addEventListener('pointerdown', function (e) { e.preventDefault(); e.stopPropagation(); vol._held = true; vol.classList.add('cbx-held'); try { vol.setPointerCapture(e.pointerId); } catch (err) {} volAt(e.clientX); });
		vol.addEventListener('pointermove', function (e) { if (vol._held) volAt(e.clientX); });
		['pointerup', 'pointercancel'].forEach(function (ev) { vol.addEventListener(ev, function () { vol._held = false; vol.classList.remove('cbx-held'); }); });
		var lastTap = 0, lastX = 0, single = null;
		shell.addEventListener('click', function (e) {
			var swallowed = shell._swallow && Date.now() - shell._swallow < 700;
			shell._swallow = 0;
			if (swallowed) return;
			var now = Date.now(), r = shell.getBoundingClientRect(), x = (e.clientX - r.left) / Math.max(1, r.width);
			if (S.dblTapSeek && now - lastTap < 320 && Math.abs(e.clientX - lastX) < 60) {
				clearTimeout(single); single = null; lastTap = 0;
				if (x < 0.34) seekBack(10); else if (x > 0.66) seekForward(10); else goFullscreen();
				return;
			}
			lastTap = now; lastX = e.clientX; clearTimeout(single);
			var cx = e.clientX, cy = e.clientY;
			// the press that started this tap already brought hidden controls back (see the document listener),
			// so "were they hidden?" has to be read from what that press found, not from the class now
			var wasIdle = !!P.idleAtDown && now - P.idleAtDown < 1200;
			P.idleAtDown = 0;
			var byTouch = lastPointer !== 'mouse';
			single = setTimeout(function () {
				single = null;
				// controls hidden: the first tap brings them back and does nothing else
				if (wasIdle || (P.block && P.block.classList.contains('cbx-idle'))) { armIdleHide(); return; }
				if (siteFlavor() || (P.block && P.block.classList.contains('cbx-adopt'))) {
					// the site's own prompts sit under our transparent shell; hand them the tap
					var under = null;
					try { P.block.style.pointerEvents = 'none'; under = document.elementFromPoint(cx, cy); } catch (err) {} finally { P.block.style.pointerEvents = ''; }
					var pressable = !!under && under.tagName !== 'VIDEO' && !!under.closest && !!under.closest(MAIN_PLAYER_SEL) && !under.closest('#cbx-block');
					// touch: a plain wrapper over the picture is not a prompt. Handing it the tap made the site toggle its
					// own (hidden) controls, and ours never got to show or hide.
					if (pressable && byTouch) { var cur = ''; try { cur = getComputedStyle(under).cursor; } catch (err) {} pressable = !!under.closest('button,a,[role="button"],input,label,[onclick]') || cur === 'pointer'; }
					if (pressable) { try { under.click(); } catch (err) {} return; }
				}
				// touch: a tap shows and hides the controls, the way every phone player works; the centre
				// button (and the bar) pause. A paused picture resumes on a tap anywhere.
				if (byTouch && S.tapToggles) { var pv = activeVideo(); if (pv && pv.paused) togglePause(); else hideControlsNow(); return; }
				togglePause();
			}, S.dblTapSeek ? 330 : 0);
		});
	}
	var ACTS = {
		pause: togglePause, mute: toggleMute, back: function () { seekBack(10); }, fwd: function () { seekForward(10); },
		live: goLive, snap: snapshotFrame, clip: function () { saveClip(); }, pip: togglePiP, fs: goFullscreen, nativefs: nativeFullscreen, panel: function () { openPanel(true); },
		fast: toggleCatchUp, slow: toggleSlow, loop: cycleLoop,
		mark: addBookmark, stats: toggleStats, rec: toggleRecording,
		quality: function () { var b = P.bar && $('button[data-act="quality"]', P.bar); if (b) openDD('quality', b, { gear: true }); },
		gear: function () { var b = P.bar && $('button[data-act="gear"]', P.bar); if (b) openDD('menu', b, { gear: true }); }
	};
	function runAct(a) { var f = ACTS[a]; if (f) f(); }
	function scrubWindow(v, w) { return Math.max(w.end - w.start, v === P.video ? (P.heldSec || S.siteKeep) : (siteDvrActive() ? keepBack() : 0), 1); }

	// within this of the buffered edge counts as live: the edge grows in segment-sized steps
	// while the playhead moves smoothly, so a live player is always a second or two "behind"
	function liveEps() { return 6; }
	// the timeline's playhead, fill and label. Called once a second with everything else and
	// once per frame while the controls are visible, so the thumb glides instead of stepping.
	// At live the thumb is pinned to the right edge; it only moves once you are actually behind.
	function paintScrub(R, v, w, dragPos) {
		var win = scrubWindow(v, w), span = Math.max(0.1, w.end - w.start);
		// the buffered edge advances in steps; between steps assume it keeps growing at 1x (capped)
		var E = P.edge || (P.edge = { v: null, end: 0, at: 0 });
		if (E.v !== v || Math.abs(w.end - E.end) > 0.01) { E.v = v; E.end = w.end; E.at = performance.now(); }
		var edge = w.end + Math.min(2.5, Math.max(0, (performance.now() - E.at) / 1000));
		var behind = Math.max(0, edge - v.currentTime);
		// our hls.js engine runs a few seconds behind the buffered edge on purpose (its live-sync target),
		// so ask it where live is; the site's player is judged by its buffer
		var h = v === P.video && P.eng ? P.eng.h : null, lsp = h && h.liveSyncPosition;
		var atLive = dragPos == null && (lsp > 0 ? v.currentTime > lsp - 3 : behind < liveEps());
		var pos = dragPos != null ? dragPos : (atLive ? 1 : 1 - behind / win);
		pos = Math.min(1, Math.max(0, pos));
		var heldFrom = Math.max(0, 1 - span / win);
		var l = (pos * 100).toFixed(2) + '%';
		if (R._posL !== l) {
			R._posL = l;
			R.fill.style.left = (heldFrom * 100).toFixed(2) + '%'; R.fill.style.width = (Math.max(0, pos - heldFrom) * 100).toFixed(2) + '%';
			R.thumb.style.left = l; R.at.style.left = l;
			R.at.style.transform = pos < 0.08 ? 'translateX(-10%)' : pos > 0.92 ? 'translateX(-90%)' : 'translateX(-50%)';
		}
		var txt = atLive ? 'live' : '−' + fmtTime(behind);
		if (R.at.textContent !== txt) R.at.textContent = txt;
		return { pos: pos, behind: behind, atLive: atLive };
	}
	// per-frame paint while the controls are showing; stops itself when the bar goes away
	function smoothLoop() {
		P.smoothRaf = null;
		var bar = P.bar; if (!bar || !bar.isConnected) return;
		// nothing to animate while hidden or idle: stop, and kickSmooth() restarts us when the controls show
		if (document.hidden || !P.block || P.block.classList.contains('cbx-idle')) return;
		if (bar._refs && !bar._refs.scrub._held) {
			var v = activeVideo();
			// paused: nothing glides. Stop; the once-a-second bar update and the next 'playing' start it again.
			// (Paused controls stay up for as long as the pause lasts: that was a callback per frame, for nothing.)
			if (!v || v.paused) return;
			// at live the thumb is pinned to the right edge: a look four times a second is plenty there
			if (bar._atLive && (bar._skip = (bar._skip || 0) + 1) % 15) { P.smoothRaf = requestAnimationFrame(smoothLoop); return; }
			var w = seekWindow(v);
			if (w) { var st = paintScrub(bar._refs, v, w, null); bar._atLive = !!(st && st.atLive); }
		}
		P.smoothRaf = requestAnimationFrame(smoothLoop);
	}
	function kickSmooth() { if (!P.smoothRaf && P.bar && P.bar.isConnected && !document.hidden) P.smoothRaf = requestAnimationFrame(smoothLoop); }
	function paintVol(vol, f) {
		var pct = (Math.min(1, Math.max(0, f)) * 100).toFixed(1) + '%';
		if (vol._pct === pct) return; vol._pct = pct;
		var fill = vol.firstChild, th = vol.lastChild;
		if (fill) fill.style.width = pct; if (th) th.style.left = pct;
		vol.setAttribute('aria-valuenow', Math.round(f * 100));
	}
	function updateBar(fromScrub) {
		var bar = P.bar; if (!bar || !bar.isConnected) return;
		var v = activeVideo(), w = seekWindow(v);
		var R = bar._refs || (bar._refs = {
			out: $('#cbx-behind', bar), scrub: $('#cbx-scrub', bar), at: $('#cbx-scrub-at', bar),
			held: $('.cbx-held', bar), fill: $('.cbx-fill', bar), thumb: $('.cbx-thumb', bar),
			pause: $('button[data-act="pause"]', bar), mute: $('button[data-act="mute"]', bar), vol: $('#cbx-vol', bar), qbtn: $('button[data-act="quality"] span', bar)
		});
		var out = R.out, scrub = R.scrub;
		var ours = v === P.video;
		bar.classList.toggle('cbx-ours', ours);
		if (!v || !w) { out.textContent = v ? 'starting…' : ''; return; }
		var span = Math.max(0.1, w.end - w.start), win = scrubWindow(v, w);
		var st = paintScrub(R, v, w, (scrub._held || fromScrub) && scrub._pos != null ? scrub._pos : null);
		var behind = st.behind, atLive = st.atLive;
		var held = R.held;
		// each buffered range is its own band, so a hole after a quality change shows as a gap
		var bands = bufferedRanges(v).map(function (rg) {
			return '<i style="left:' + (Math.max(0, 1 - (w.end - rg[0]) / win) * 100).toFixed(2) + '%;width:' + (Math.min(1, (rg[1] - rg[0]) / win) * 100).toFixed(2) + '%"></i>';
		}).join('');
		if (held._html !== bands) { held._html = bands; held.innerHTML = bands; }
		var t = ours && P.eng ? P.eng.active() : null, mb = t && t.bw ? ' · ' + Math.round(span * t.bw / 8 / 1048576) + ' MB' : '';
		var sdvr = !ours && siteDvrActive();
		var atMax = ours ? span >= (P.heldSec || S.siteKeep) - 8 : (sdvr && span >= keepBack() - 8), warming = (ours && !P.armed) || (sdvr && span < 10);
		bar.classList.toggle('cbx-warming', warming);
		var rate = v.playbackRate !== 1 ? v.playbackRate + '× · ' : '';
		out.textContent = warming ? 'Rewind ready in ' + Math.max(0, Math.ceil(10 - span)) + 's…'
			: rate + (atLive ? fmtTime(span) + (atMax ? ' (max)' : (win > span + 1 ? ' / ' + fmtTime(win) : '')) + ' held' + mb : '−' + fmtTime(behind) + ' of ' + fmtTime(span) + (atMax ? ' (max)' : ''));
		out.title = atMax && P.heldNote ? P.heldNote : '';
		bar.classList.toggle('cbx-behind-live', !atLive);
		if (v.playbackRate > 1 && behind < 1.5) setRate(v, 1);
		// loop markers on the timeline; drop the loop once its start is trimmed away
		if (P.loopA != null && P.loopA < w.start) { clearLoop(); toast('Loop dropped — its start left the buffer'); }
		var mk = R.marks || (R.marks = (function () { var d = el('div', { 'class': 'cbx-marks' }); scrub.appendChild(d); return d; })());
		var markAt = function (t) { return ((1 - (w.end - t) / win) * 100).toFixed(2) + '%'; };
		pruneMarks(w);
		var mkHtml = (P.loopA != null ? '<i style="left:' + markAt(P.loopA) + '"></i>' : '') + (P.loopB != null ? '<i style="left:' + markAt(P.loopB) + '"></i>' : '') +
			(P.marks || []).map(function (t) { return '<i class="cbx-bm" style="left:' + markAt(t) + '"></i>'; }).join('') +
			(S.tipMarks ? (P.tips || []).map(function (t) { return '<i class="cbx-tip" style="left:' + markAt(t) + '"></i>'; }).join('') : '');
		if (mk._html !== mkHtml) { mk._html = mkHtml; mk.innerHTML = mkHtml; }
		if (S.statsOverlay) renderStats(v, w, behind);
		var pIcon = v.paused ? I.play : I.pause; if (R.pause._icon !== pIcon) { R.pause._icon = pIcon; R.pause.innerHTML = pIcon; }
		var bigBtn = P.block && $('#cbx-bigplay', P.block);
		if (bigBtn && bigBtn._icon !== pIcon) { bigBtn._icon = pIcon; bigBtn.innerHTML = pIcon; bigBtn.classList.toggle('cbx-bp-pause', !v.paused); bigBtn.setAttribute('aria-label', v.paused ? 'Play' : 'Pause'); }
		if (P.block) P.block.classList.toggle('cbx-paused', !!v.paused && v.readyState > 0);
		kickSmooth();
		var mIcon = (v.muted || v.volume === 0) ? I.muted : I.vol; if (R.mute._icon !== mIcon) { R.mute._icon = mIcon; R.mute.innerHTML = mIcon; }
		var vol = R.vol;
		if (!vol._held) paintVol(vol, v.muted ? 0 : v.volume);
		paintQualityBtn(R.qbtn, v);
		var recBtn = R.rec || (R.rec = $('#cbx-rec', bar)), wsig = (R.qbtn ? R.qbtn.textContent.length : 0) + '|' + (recBtn ? recBtn.className : '');
		if (bar._wsig !== wsig) { var firstSig = bar._wsig === undefined; bar._wsig = wsig; if (!firstSig) fitBar(true); }
	}

	/* ---- deep rewind ---- */
	function installDvr() {
		var user = roomName();
		if (!wantDeep() || P.suspended || P.forcedSite || !user || P.busy) return;
		if (P.video && P.video.isConnected) return;
		// asleep: only the way back (or the play button) starts a player again, never a routine tick
		if (P.sleeping) return;
		// one player: the engine goes under the site's own video instead (see installAdopt)
		if (P.adopted || adoptActive()) return;
		// a phone cannot start playback in a background tab; a player started there only burned data
		// until the startup watch declared the room dead. It starts when the tab is shown (see resumeCheck).
		if (document.hidden && isPhone()) return;
		if (!ensureBlock()) return;
		var site = P.site;
		P.busy = true; P.armed = false;
		hlsFor(user).then(function (url) {
			if (!url || roomName() !== user || (P.video && P.video.isConnected)) return;
			var v = el('video', { 'class': 'cbx-dvr', playsinline: '', 'webkit-playsinline': 'true', autoplay: '', muted: '' });
			v.muted = true;
			P.shell.appendChild(v);
			P.video = v;
			// the site's picture stays up until ours has a frame; the block is see-through meanwhile
			site.muted = true;
			if (P.block) P.block.classList.add('cbx-warm');
			v.addEventListener('playing', function onFirst() { v.removeEventListener('playing', onFirst); if (v === P.video && v.readyState > 2) handover(); });
			// the fit (cover or contain) and the pan range depend on the stream's aspect: refresh them
			// once it is known, and again if the broadcaster switches between portrait and landscape
			['loadedmetadata', 'resize'].forEach(function (ev) { v.addEventListener(ev, function () { if (v === P.video) applyTransform(); }); });
			document.documentElement.classList.add('cep-dvr-on');
			var E = P.eng = hlsEngine(v, url);
			var onFatal = function () { log('dvr fatal, rebuilding'); dropDvr(); setTimeout(function () { if (roomName() === user) installDvr(); }, 2500); };
			return E.attach(onFatal).then(function () {
				v.addEventListener('playing', function once() { v.removeEventListener('playing', once); try { E.goLive(); } catch (e) {} });
				installMediaSession(v, user);
				var p = v.play(); if (p && p.catch) p.catch(function (e) { if (!e || e.name !== 'AbortError') log('play refused', e && e.name); });
				startupWatch(v, site, user);
			});
		}).catch(function (e) {
			// held back after a failure: said once when it failed, not on every pass while it waits
			if (!(e && e.cooldown)) { log('dvr', e && (e.message || e)); toast('Deep rewind could not load: ' + (e && e.message || e), 5000); }
			if (P.video || !(e && e.cooldown)) dropDvr();
		}).then(function () { P.busy = false; });
	}
	// lock screen / headset controls on phones
	function installMediaSession(v, user) {
		if (!S.mediaSession || !('mediaSession' in navigator)) return;
		try {
			navigator.mediaSession.metadata = new MediaMetadata({ title: user, artist: 'Chaturbate — Enhanced Plus' });
			var ms = navigator.mediaSession, set = function (a, f) { try { ms.setActionHandler(a, f); } catch (e) {} };
			set('play', function () { if (v === P.video) { P.userPaused = false; var p = v.play(); if (p && p.catch) p.catch(function () {}); } });
			set('pause', function () { if (v === P.video) { P.userPaused = true; v.pause(); } });
			set('seekbackward', function (d) { seekBack((d && d.seekOffset) || 10); });
			set('seekforward', function (d) { seekForward((d && d.seekOffset) || 10); });
			set('seekto', function (d) { if (d && typeof d.seekTime === 'number' && v === P.video) seekTo(v, d.seekTime, true); });
		} catch (e) { log('media session', e && e.message); }
	}
	// our player has its first frame: swap the pictures in one tick
	function handover() {
		var site = P.site && P.site.isConnected ? P.site : null;
		var poster = $('#cbx-poster'); if (poster) { freeCanvas(poster); poster.remove(); }
		if (P.wake && P.video) restoreSound(P.video);
		if (P.block) P.block.classList.remove('cbx-warm');
		if (site) { site.style.opacity = '0'; site.style.pointerEvents = 'none'; }
	}
	function startupWatch(v, site, user) {
		var tries = 0, lastT = -1, lastEnd = 0, stuck = 0;
		var check = setInterval(function () {
			if (v !== P.video || !v.isConnected) { clearInterval(check); return; }
			// browsers hold playback back in a hidden tab: not starting there says nothing about the room
			if (document.hidden) { stuck = 0; return; }
			tries++;
			if (isLivePlaying(v)) {
				clearInterval(check); P.attempt = 0; handover();
				log('dvr running at', v.currentTime.toFixed(1), 'after', tries * 2, 's, decoding', v.videoHeight + 'p (auto until 10s held)');
				toast('Deep rewind on — press M or the speaker to unmute');
				armDvr(v);
				setTimeout(function () {
					if (v !== P.video || !v.getVideoPlaybackQuality) return;
					if (v.getVideoPlaybackQuality().totalVideoFrames === 0 && !document.hidden) { log('no frames painted after 6s; restarting low'); dropDvr(); P.attempt = 1; setTimeout(function () { if (roomName() === user) installDvr(); }, 800); }
				}, 6000);
				return;
			}
			var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0;
			var moving = v.currentTime > lastT + 0.2 || end > lastEnd + 0.2;
			lastT = v.currentTime; lastEnd = end; stuck = moving ? 0 : stuck + 1;
			log('dvr starting…', 'ready=' + v.readyState, 'buffered=[' + ranges(v.buffered).join(' ') + ']', moving ? 'progressing' : 'stalled ' + stuck);
			if (v.paused && !P.userPaused) { var rp = v.play(); if (rp && rp.catch) rp.catch(function () {}); }
			// a site player we parked ourselves (paused, clock running) is not a dead stream server
			var edgeSick = (v._cbxTimeouts || 0) >= 2 && !v.buffered.length, siteDead = !site._cbxClock && !isLivePlaying(site);
			if (edgeSick || stuck >= 5 || tries >= 20 || (siteDead && tries >= 5) || (v._cbxErrors || 0) >= 6) {
				clearInterval(check); dropDvr();
				if (!siteDead && P.attempt++ < 2) { log(edgeSick ? 'edge not answering, retrying with a fresh session' : 'did not start, retrying (attempt ' + P.attempt + ')'); setTimeout(function () { if (roomName() === user) installDvr(); }, 1500); return; }
				P.suspended = true; P.attempt = 0; removeBlock(); clearSiteClock();
				setTimeout(function () { qualityBusy = false; applyQuality(); }, 1200);
				toast(siteDead ? 'The stream server is not responding for this room' : 'Deep rewind could not start on this room — site player only', 5000);
			}
		}, 2000);
	}
	// the site's own watchdog treats a paused player as stuck and rebuilds it,
	// which tears our block down; slowing it instead keeps it "progressing"
	// while it fetches about a quarter of the data
	function parkSite(on) {
		if (P.adopted) return;     // one player: there is nothing to park
		var site = P.site && P.site.isConnected ? P.site : null; if (!site) return;
		try {
			if (on) {
				// our player carries the picture. The site's native player is parked: paused, so it
				// downloads and decodes nothing. Its 10 s dead-man timer wants currentTime to keep
				// advancing between timeupdate events; the element's clock keeps running while
				// parked (see the getter) and the heartbeat sends the events. One stream.
				site.muted = true;
				if (site.playbackRate !== 1) site.playbackRate = 1;
				startSiteClock(site);
				if (!site.paused) { site.pause(); log('site player parked: paused, clock running'); }
			}
		} catch (e) {}
	}
	function armDvr(v) {
		var steady = 0, lastT = v.currentTime;
		var arm = setInterval(function () {
			if (v !== P.video || !v.isConnected) { clearInterval(arm); return; }
			var w = seekWindow(v), span = w ? w.end - w.start : 0;
			steady = (v.currentTime > lastT + 0.5 && !v.paused) ? steady + 1 : 0; lastT = v.currentTime;
			if (span >= 10 && steady >= 3) {
				clearInterval(arm); P.armed = true;
				pinTrack(bestTrackUnder(dvrCap()));
				updateBar();
				log('dvr armed: ' + fmtTime(span) + ' held, quality pinned, rewind on');
				setTimeout(function () { if (v === P.video) parkSite(true); }, 1500);
				watchDvr(v);
			}
		}, 1000);
	}
	function watchDvr(v) {
		var lastEnd = 0, stuck = 0, unlocked = false, ticks = 0, waitingSince = 0, lastT = v.currentTime;
		var onWaiting = function () { if (!waitingSince) waitingSince = Date.now(); };
		var onPlaying = function () { waitingSince = 0; P.stalls = P.stalls || 0; };
		v.addEventListener('waiting', onWaiting); v.addEventListener('playing', onPlaying);
		var wd = setInterval(function () {
			if (v !== P.video || !v.isConnected) { clearInterval(wd); v.removeEventListener('waiting', onWaiting); v.removeEventListener('playing', onPlaying); return; }
			if (++ticks % 15 === 1) {
				var r = v.getBoundingClientRect(), q = v.getVideoPlaybackQuality ? v.getVideoPlaybackQuality() : null, w = seekWindow(v) || { start: 0, end: 0 };
				log('on screen', Math.round(r.width) + 'x' + Math.round(r.height), 'decoded', v.videoWidth + 'x' + v.videoHeight, 'ready', v.readyState, 'frames', q ? q.totalVideoFrames + ' (dropped ' + q.droppedVideoFrames + ')' : '?', 'held', fmtTime(w.end - w.start));
			}
			if (P.site && !P.site.paused && ticks % 5 === 0) parkSite(true);
			if (v.paused || document.hidden) { waitingSince = 0; lastT = v.currentTime; return; }
			var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0;
			var progressed = v.currentTime > lastT + 0.2; lastT = v.currentTime;
			// a stall is: the browser said 'waiting', nothing has moved, and the
			// loader has not added anything for two ticks in a row
			var stalled = waitingSince && !progressed && end <= lastEnd + 0.2;
			lastEnd = end;
			stuck = stalled ? stuck + 1 : 0;
			P.fwdBuf = Math.max(0, end - v.currentTime);
			if (stalled && Date.now() - waitingSince > 4000 && stuck === 2 && P.eng) { P.stalls = (P.stalls || 0) + 1; log('stalled 4s, nudging loader'); P.eng.retry(); }
			if (stuck >= 5 && P.eng && !unlocked) { unlocked = true; log('pinned rendition starving, back to auto'); P.eng.auto(); toast('This rendition stalled — quality set to auto'); }
			if (!stalled) unlocked = false;
		}, 2000);
	}
	function dropDvr(opts) {
		opts = opts || {};
		// one player: there is no element of ours to remove; the engine lets go of the site's video
		if (P.adopted) { releaseAdopt('player dropped', null, !opts.noRestore); return; }
		var v = P.video, E = P.eng;
		P.video = null; P.eng = null; P.armed = false; P.ring = null; P.native = false; P.fwdBuf = null; P.marks = []; P.tips = []; P.thumbs = []; freeSheet(); clearLoop();
		if (E) E.destroy();
		if (v) { unchain(v); try { v.remove(); } catch (e) {} }
		document.documentElement.classList.remove('cep-dvr-on');
		var site = P.site && P.site.isConnected ? P.site : siteVideo();
		// asleep in a background tab: the site's player stays parked, or it would start streaming there
		if (opts.keepParked) { updateBar(); return; }
		if (site) {
			try { if (site.playbackRate !== 1) site.playbackRate = 1; } catch (e) {}
			site.style.opacity = ''; site.style.pointerEvents = '';
			if (userHasInteracted) site.muted = false; else toast('Click the video to get sound back');
			try { var sp = site.play(); if (sp && sp.catch) sp.catch(function () {}); } catch (e) {}
			// handing over to the site player with rewind: it was parked behind live at its
			// lowest rendition while deep rewind ran, so catch it up and raise the quality
			if (siteDvrActive()) setTimeout(function () {
				var s2 = P.site && P.site.isConnected ? P.site : siteVideo(), w2 = s2 && seekWindow(s2);
				if (w2 && s2.currentTime < w2.end - 3) { try { setTime(s2, w2.end - 0.5); } catch (e) {} }
				qualityBusy = false; applyQuality();
			}, 800);
		}
		updateBar();
	}
	function removeDvr(opts) { P.sleeping = false; dropDvr(opts); removeBlock(); }
	function backToSitePlayer() { P.suspended = true; P.forcedSite = true; removeDvr(); clearSiteClock(); toast('Plain site player for this page — press P for rewind'); updatePlayerToggle(); }
	function onPlayerNavigate() { P.suspended = false; P.forcedSite = false; P.attempt = 0; P.userPaused = false; A.blocked = false; A.why = ''; A.adoptions = []; A.assigns = []; A.told = false; A.black = 0; removeDvr({ noRestore: true }); xform.zoom = roomZoom(); xform.panX = xform.panY = 0; }

	/* ================================================================== *
	 * one player (step 2 of retiring the second player)
	 *
	 * Where the site plays its .m3u8 natively (the mobile site) there is no
	 * buffer to hook: a native pipeline only ever holds the playlist's few
	 * seconds. The second player in the shell was the answer so far, with
	 * the site's own player parked under it: two elements, a fake clock, a
	 * handover, a double download while warming up, and none of the site's
	 * own buttons acting on the picture that is actually shown.
	 *
	 * Here the site keeps its player and only the engine changes. The
	 * site's own <video> is guarded the way its currentTime already was:
	 * an .m3u8 the site assigns is noted instead of loaded, and hls.js is
	 * attached to that same element. The site stays in charge of what it
	 * is in charge of (layout, its buttons, sound, room changes); we hold
	 * the rewind window. One element, one decoder, one download.
	 *
	 * It is a guest in somebody else's player, so it gives up easily:
	 * a start that does not come, a site that keeps reloading, an element
	 * that cannot be guarded all end in "release, restore the native
	 * source, use the overlay player for this page".
	 * ================================================================== */

	var A = { on: false, v: null, ours: 0, siteUrl: '', assigns: [], adoptions: [], blocked: false, why: '', dead: false };
	var srcDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
	var ADOPT_FAILS_KEY = 'cbx-adopt-fails';
	function oursDo(fn) { A.ours++; try { return fn(); } finally { A.ours--; } }
	function engineMode() { return S.engine === 'adopt' || S.engine === 'overlay' ? S.engine : 'auto'; }
	function adoptFails() { try { return parseInt(sessionStorage.getItem(ADOPT_FAILS_KEY), 10) || 0; } catch (e) { return 0; } }
	function adoptWanted() {
		if (A.blocked || engineMode() === 'overlay' || !canUseMse() || !srcDesc || !srcDesc.set || !requiredHlsPossible()) return false;
		// automatic: touch layouts only (that is where the site player is native), and not after it failed twice in this tab
		if (engineMode() === 'auto' && (!wantsTouchLayout() || adoptFails() >= 2)) return false;
		return true;
	}
	function requiredHlsPossible() { return !!requiredHls() || Date.now() - hlsGaveUpAt > 60000; }
	// the native site player is there to be adopted (or already is)
	function adoptActive() { return P.adopted || (wantDeep() && adoptWanted()); }

	function guardSiteSrc(v) {
		if (v._cbxSrcGuard) return true;
		try {
			var mine = function () { return A.on && A.v === v && !A.ours; };
			Object.defineProperty(v, 'src', {
				configurable: true, enumerable: true,
				get: function () { return srcDesc.get.call(v); },
				set: function (u) {
					var str = u == null ? '' : String(u);
					if (mine() && !/^blob:/i.test(str)) { siteAssigned(v, str); return; }
					keepRoutedAudible(v, str);
					srcDesc.set.call(v, u);
				}
			});
			v.load = function () {
				if (mine()) { log('adopt: a load() from the site was held back'); return; }
				return HTMLMediaElement.prototype.load.apply(v, arguments);
			};
			v.setAttribute = function (n, val) {
				if (mine() && /^src$/i.test(String(n)) && !/^blob:/i.test(String(val))) { siteAssigned(v, val == null ? '' : String(val)); return; }
				if (/^src$/i.test(String(n))) keepRoutedAudible(v, val);
				return Element.prototype.setAttribute.apply(v, arguments);
			};
			v.removeAttribute = function (n) {
				if (mine() && /^src$/i.test(String(n))) { siteAssigned(v, ''); return; }
				return Element.prototype.removeAttribute.apply(v, arguments);
			};
			// a play() that is nobody's tap, while the viewer has paused: the site's watchdog, not the viewer
			v.play = function () {
				if (mine() && P.userPaused && Date.now() - lastPointerAt > 1200) { log('adopt: a play() from the site was held back (paused by the viewer)'); return Promise.resolve(); }
				return HTMLMediaElement.prototype.play.apply(v, arguments);
			};
			v._cbxSrcGuard = true;
			return true;
		} catch (e) { log('adopt: the element could not be guarded:', e && e.message); return false; }
	}
	function siteAssigned(v, u) {
		var now = Date.now();
		A.assigns = A.assigns.filter(function (t) { return now - t < 60000; }); A.assigns.push(now);
		A.lastAssignAt = now;
		log('adopt: the site assigned ' + (u ? 'a source (' + u.replace(/\?.*$/, '').slice(-48) + ')' : 'an empty source'));
		// an empty or non-HLS source is the site ending playback (offline, a private show, its picture fallback): it is the site's call
		if (!u || !/\.m3u8(\?|#|$)/i.test(u)) { releaseAdopt('the site ended playback', u, false); removeBlock(); return; }
		A.siteUrl = u;
		if (A.assigns.length > 6) { failAdopt('the site keeps reloading its player'); return; }
		// a fresh address while our engine is healthy changes nothing; once ours has died it is the way back
		if (P.eng && P.eng.h && !A.dead) return;
		readopt('site');
	}

	function adoptPlay(v) { playOrMuted(v); }
	function startEngineOn(v, url, user) {
		var E = P.eng = hlsEngine(v, url);
		A.dead = false;
		var onFatal = function () { log('adopt: the engine gave up'); A.dead = true; setTimeout(function () { if (P.adopted && P.video === v && roomName() === user) readopt('fatal'); }, 1500); };
		return E.attach(onFatal).then(function () {
			if (P.eng !== E) return;
			v.addEventListener('playing', function once() { v.removeEventListener('playing', once); if (P.eng === E && (!P.wake || P.wake.live !== false)) { try { E.goLive(); } catch (e) {} } P.wake = null; });
			installMediaSession(v, user);
			adoptPlay(v);
			adoptWatch(v, user, E);
		});
	}
	function installAdopt() {
		var user = roomName();
		if (!user || P.busy || P.suspended || P.forcedSite || P.adopted || P.sleeping || !adoptActive()) return;
		if (document.hidden && isPhone()) return;
		if (!ensureBlock()) return;
		var site = P.site;
		if (!site || !site.isConnected) return;
		if (!guardSiteSrc(site)) { A.blocked = true; A.why = 'the site video could not be guarded'; removeBlock(); return; }
		var now = Date.now();
		A.adoptions = A.adoptions.filter(function (t) { return now - t < 60000; }); A.adoptions.push(now);
		if (A.adoptions.length > 4) { failAdopt('adopted too often in a minute'); return; }
		P.busy = true; P.armed = false;
		var nativeUrl = ''; try { nativeUrl = srcDesc.get.call(site) || site.currentSrc || ''; } catch (e) {}
		hlsFor(user).catch(function () { return null; }).then(function (url) {
			if (roomName() !== user || !site.isConnected || P.adopted || P.forcedSite) return;
			url = url || (/\.m3u8/i.test(nativeUrl) ? nativeUrl : null);
			if (!url) { log('adopt: no stream address'); return; }
			A.on = true; A.v = site; A.siteUrl = nativeUrl; A.assigns = [];
			site._cbxAdopted = true; site.classList.add('cbx-adopted');
			P.video = site; P.site = site; P.adopted = true;
			document.documentElement.classList.add('cep-dvr-on');
			// a stream that (re)starts reports its size anew, and the site lays its player out again on that
			['loadedmetadata', 'resize'].forEach(function (ev) { if (!site._cbxXf) site.addEventListener(ev, function () {
				if (site !== P.video) return;
				applyTransform();
				clearTimeout(site._cbxRelay); site._cbxRelay = setTimeout(function () { if (site === P.video && P.block) { healBoxHeight('video ' + ev); fitBar(true); } }, 300);
			}); });
			site._cbxXf = true;
			log('adopt: our engine goes under the site\'s own video');
			return startEngineOn(site, url, user);
		}).catch(function (e) {
			log('adopt', e && (e.message || e));
			failAdopt('the engine could not be attached');
		}).then(function () { P.busy = false; });
	}
	function adoptWatch(v, user, E) {
		var tries = 0;
		var check = setInterval(function () {
			if (!P.adopted || P.video !== v || P.eng !== E || !v.isConnected) { clearInterval(check); return; }
			if (document.hidden || P.sleeping) return;
			tries++;
			var flowing = v.readyState >= 3 && v.buffered.length > 0;
			if (flowing) {
				clearInterval(check); P.attempt = 0;
				watchPicture(v, E);
				log('adopt: running after ' + tries * 2 + 's, decoding ' + v.videoHeight + 'p' + (v.paused ? ' (waiting for a tap)' : ''));
				if (!A.told) { A.told = true; toast('Rewind on — one player'); }
				armDvr(v);
				return;
			}
			log('adopt starting…', 'ready=' + v.readyState, 'buffered=[' + ranges(v.buffered).join(' ') + ']');
			if (v.paused) adoptPlay(v);
			if (tries >= 8 || (v._cbxErrors || 0) >= 6 || ((v._cbxTimeouts || 0) >= 2 && !v.buffered.length && tries >= 5)) { clearInterval(check); failAdopt('the stream did not start'); }
		}, 2000);
	}
	/* Data flowing is not a picture. After an engine is put under a <video> that sat in a background
	 * tab, WebKit sometimes plays (the clock runs, sound comes) and paints nothing: a black player. The
	 * overlay player always had a check for that at its first start; the one-player path had none, and
	 * every return to a tab is such a start. Frames are counted: a clock that runs for three seconds
	 * without one new frame gets a small seek first (that alone wakes the decoder most times), then a
	 * fresh engine, and after that the overlay player takes over for this page. */
	function framesOf(v) {
		var a = -1, b = -1;
		try { if (v.getVideoPlaybackQuality) a = v.getVideoPlaybackQuality().totalVideoFrames; } catch (e) {}
		try { if (typeof v.webkitDecodedFrameCount === 'number') b = v.webkitDecodedFrameCount; } catch (e) {}
		return Math.max(a, b);
	}
	function watchPicture(v, E) {
		var f0 = framesOf(v), t0 = v.currentTime, nudged = false, ticks = 0;
		if (f0 < 0) { try { sessionStorage.removeItem(ADOPT_FAILS_KEY); } catch (e) {} return; }      // this browser does not count frames
		var iv = setInterval(function () {
			if (!P.adopted || P.video !== v || P.eng !== E || !v.isConnected) { clearInterval(iv); return; }
			if (document.hidden || P.sleeping || v.paused || v.seeking) { f0 = framesOf(v); t0 = v.currentTime; return; }
			var f = framesOf(v);
			if (f > f0) {
				clearInterval(iv); A.black = 0;
				try { sessionStorage.removeItem(ADOPT_FAILS_KEY); } catch (e) {}
				return;
			}
			if (++ticks > 20) { clearInterval(iv); return; }
			if (v.currentTime - t0 < 3) return;         // the clock has to have run for a while first
			if (!nudged) {
				nudged = true; t0 = v.currentTime;
				log('adopt: the clock runs but no frame is painted; nudging the decoder');
				try { setTime(v, v.currentTime + 0.1); } catch (e) {}
				return;
			}
			clearInterval(iv);
			A.black = (A.black || 0) + 1;
			log('adopt: black picture (' + A.black + ')');
			if (A.black >= 2) { A.black = 0; failAdopt('the picture stayed black'); }
			else { A.dead = true; readopt('black picture'); }
		}, 1000);
	}
	// a fresh engine on a fresh address, same element, the guard stays up
	function readopt(why) {
		var v = A.v, user = roomName();
		if (!P.adopted || !v || !v.isConnected || !user) return;
		var now = Date.now();
		A.adoptions = A.adoptions.filter(function (t) { return now - t < 60000; }); A.adoptions.push(now);
		if (A.adoptions.length > 4) { failAdopt('the engine had to be restarted too often'); return; }
		if (P.busy) return;
		P.busy = true; P.armed = false;
		log('adopt: fresh engine (' + why + ')');
		var old = P.eng; P.eng = null; P.ring = null; P.thumbs = []; freeSheet(); P.marks = []; P.tips = []; clearLoop();
		if (old) old.destroy();
		hlsFor(user).catch(function () { return null; }).then(function (url) {
			if (!P.adopted || A.v !== v || roomName() !== user) return;
			url = url || (/\.m3u8/i.test(A.siteUrl) ? A.siteUrl : null);
			if (!url) { failAdopt('no stream address'); return; }
			return startEngineOn(v, url, user);
		}).catch(function (e) { log('adopt', e && (e.message || e)); failAdopt('the engine could not be restarted'); })
			.then(function () { P.busy = false; });
	}
	// let go of the site's video. passValue: what the site just tried to assign ('' for "cleared"), which then
	// goes through. restore: put the native stream back ourselves (we are leaving, the site did nothing).
	function releaseAdopt(why, passValue, restore) {
		var v = A.v, E = P.eng, user = roomName();
		log('adopt: released — ' + why);
		A.on = false;      // from here on the site's own source changes (and the engine's clean-up) go straight through
		P.video = null; P.eng = null; P.adopted = false; P.armed = false; P.ring = null; P.native = false; P.fwdBuf = null; P.marks = []; P.tips = []; P.thumbs = []; freeSheet(); clearLoop();
		if (E) E.destroy();
		document.documentElement.classList.remove('cep-dvr-on');
		var pill = $('#cbx-soundpill'); if (pill) pill.remove();
		if (v) {
			v._cbxAdopted = false; v.classList.remove('cbx-adopted');
			try { v.style.transform = ''; v.style.filter = ''; if (v.playbackRate !== 1) v.playbackRate = 1; } catch (e) {}
			if (v.isConnected) {
				if (passValue != null) {
					try { if (passValue) { keepRoutedAudible(v, passValue); srcDesc.set.call(v, passValue); } else { Element.prototype.removeAttribute.call(v, 'src'); HTMLMediaElement.prototype.load.call(v); } } catch (e) {}
				} else if (!restore && A.siteUrl && A.lastAssignAt && Date.now() - A.lastAssignAt < 8000) {
					// a room change where the site set the new stream first and changed the address second: what it
					// assigned a moment ago was held back by the guard, so it goes through now
					try { keepRoutedAudible(v, A.siteUrl); srcDesc.set.call(v, A.siteUrl); var pn = HTMLMediaElement.prototype.play.call(v); if (pn && pn.catch) pn.catch(function () {}); } catch (e) {}
				} else if (restore) {
					var last = A.siteUrl;
					var put = function (url) {
						if (!v.isConnected || A.on || !url) return;
						var cur = ''; try { cur = srcDesc.get.call(v); } catch (e) {}
						if (cur && !/^blob:/i.test(cur)) return;      // the site got there first
						try { keepRoutedAudible(v, url); srcDesc.set.call(v, url); var p = HTMLMediaElement.prototype.play.call(v); if (p && p.catch) p.catch(function () {}); } catch (e) {}
					};
					if (user) hlsFor(user).then(function (url) { put(url || last); }, function () { put(last); }); else put(last);
				}
			}
		}
		A.v = null; A.lastAssignAt = 0;
		if (v && passValue == null) healOrphan(v);
		updateBar();
	}
	// whatever the order of events was, the site's video is never left without a source because of us
	function healOrphan(v) {
		setTimeout(function () {
			if (!v.isConnected || A.on || P.adopted) return;
			var cur = ''; try { cur = srcDesc.get.call(v) || v.currentSrc || ''; } catch (e) {}
			if (cur || v.srcObject || v.querySelector('source')) return;
			var user = roomName(); if (!user) return;
			log('adopt: the site video was left without a source; restoring it');
			hlsFor(user).then(function (url) {
				if (!url || !v.isConnected || A.on) return;
				var now = ''; try { now = srcDesc.get.call(v) || ''; } catch (e) {}
				if (now) return;
				try { keepRoutedAudible(v, url); srcDesc.set.call(v, url); var p = HTMLMediaElement.prototype.play.call(v); if (p && p.catch) p.catch(function () {}); } catch (e) {}
			}).catch(function () {});
		}, 2500);
	}
	function failAdopt(why) {
		log('adopt: FAILED — ' + why);
		A.why = why;
		try { sessionStorage.setItem(ADOPT_FAILS_KEY, String(adoptFails() + 1)); } catch (e) {}
		A.blocked = true;         // this page uses the overlay player from here on
		if (P.adopted) releaseAdopt(why, null, true);
		removeBlock();
		P.busy = false;
		toast(engineMode() === 'adopt' ? 'One player could not hold here (' + why + ')' : 'One player did not hold here — using the overlay player', 5000);
		setTimeout(function () { P.suspended = false; P.attempt = 0; playerTick(); }, 1200);
	}

	/* ---- phones: picture height and rotation ---- */
	// The site lays the player box out itself and rewrites its height on its own passes; an inline
	// height of ours only ever fought it. A stylesheet rule with !important beats inline styles for
	// good, so the height lives in one CSS variable: the box takes it, the panel under the box moves
	// with it. Portrait only; nothing is touched until the handle is dragged.
	function phoneNaturalHeight() { var ref = P.hbox || P.box; return Math.max(120, Math.round(Math.min(ref ? ref.clientWidth : innerWidth, document.documentElement.clientWidth || innerWidth) * 9 / 16)); }
	/* The handle needs the container that sits directly above the site's absolutely positioned panel
	 * (the tabs and the chat): that container takes the height, the panel moves with it. It used to be
	 * assumed that this is the box the player is mounted in. On the portrait layout it is not: the
	 * block is mounted in the inner, wider-than-the-screen wrapper (see visibleRect), which has no
	 * panel beside it, so the handle silently never appeared. Now the two are found separately: the
	 * mount box stays, and the layout box is looked for a few levels up. */
	function findLayoutBox() {
		var n = P.box, seen = [], W = document.documentElement.clientWidth || innerWidth;
		for (var i = 0; i < 7 && n && n !== document.body && n.parentElement; i++, n = n.parentElement) {
			var sib = n.nextElementSibling;
			while (sib && (sib.id === 'cbx-hgrip' || sib.tagName === 'SCRIPT' || sib.tagName === 'STYLE')) sib = sib.nextElementSibling;
			var pos = sib ? getComputedStyle(sib).position : '-';
			seen.push(n.tagName.toLowerCase() + (n.id ? '#' + n.id : '') + '>' + pos);
			if (!sib || pos !== 'absolute') continue;
			var nr = n.getBoundingClientRect(), sr = sib.getBoundingClientRect();
			// the panel under the player, not some layer on top of it
			if (sr.top >= nr.bottom - 24 && sr.height > 60 && nr.width <= W + 2) return { box: n, below: sib };
		}
		if (findLayoutBox.said !== seen.join(' ')) { findLayoutBox.said = seen.join(' '); log('height handle: no container above an absolutely positioned panel (looked at: ' + seen.join(', ') + ')'); }
		return null;
	}
	// what the site itself gives the box when nothing of ours is applied
	function siteBoxHeight() {
		var root = document.documentElement, had = root.classList.contains('cbx-ph'), h = 0;
		if (had) root.classList.remove('cbx-ph');
		try { h = P.hbox ? Math.round(P.hbox.getBoundingClientRect().height) : 0; } catch (e) {}
		if (had) root.classList.add('cbx-ph');
		return h;
	}
	/* The container we hold and the site's inner player can drift apart: the site sizes the inner one
	 * itself and does so again whenever it likes. Without a height of the viewer's own, ours simply
	 * follows the site (measure again, pin to that). With one, the site is asked to lay out again: the
	 * container moves by a pixel for one frame, which is what made it follow while the handle was dragged. */
	function healBoxHeight(why) {
		var root = document.documentElement;
		if (!P.hbox || !P.box || !P.hgrip || !P.hbox.isConnected || !root.classList.contains('cbx-ph') || P.hgrip.classList.contains('cbx-dragging')) return;
		var want = P.hbox.getBoundingClientRect().height, has = P.box.getBoundingClientRect().height;
		if (!want || !has || Math.abs(want - has) < 4) { healBoxHeight.n = 0; return; }
		if (Date.now() - (healBoxHeight.at || 0) < 1500) return;
		healBoxHeight.at = Date.now();
		if (!S.phoneHeight) {
			var sh = siteBoxHeight();
			if (sh && Math.abs(sh - want) >= 4) {
				log('the site now gives the player ' + sh + ' (was pinned at ' + Math.round(want) + ', ' + why + '): following it');
				P.hboxSite = sh; root.style.setProperty('--cbx-ph', sh + 'px');
				if (P.block) P.block._cbxClips = null;
				setTimeout(function () { fitBar(); applyTransform(); }, 80);
				return;
			}
		}
		if ((healBoxHeight.n || 0) >= 3) return;
		healBoxHeight.n = (healBoxHeight.n || 0) + 1;
		log('player is ' + Math.round(has) + ' tall inside a ' + Math.round(want) + ' container (' + why + '): asking the site to lay it out again');
		var h = parseInt(root.style.getPropertyValue('--cbx-ph'), 10) || Math.round(want);
		root.style.setProperty('--cbx-ph', (h + 1) + 'px');
		requestAnimationFrame(function () {
			root.style.setProperty('--cbx-ph', h + 'px');
			try { window.dispatchEvent(new Event('resize')); } catch (e) {}
			setTimeout(function () { fitBar(); applyTransform(); }, 150);
		});
	}
	function panelAfter(n) {
		var sib = n && n.nextElementSibling;
		while (sib && (sib.id === 'cbx-hgrip' || sib.tagName === 'SCRIPT' || sib.tagName === 'STYLE')) sib = sib.nextElementSibling;
		return sib;
	}
	/* Our height only works as a pair: the container takes it, the panel under it starts below it. The
	 * panel is the site's, and the site re-renders it (a new element, or the same one with its attributes
	 * reset): it then loses our mark, goes back to where the site wants it, and covers the lower part of a
	 * player that is held taller. That was the bar on top of the tabs "after some time". The pair is
	 * checked whenever that part of the page changes and in the routine pass, and put right. */
	function assertPhoneLayout(why) {
		var root = document.documentElement;
		if (!P.hgrip || !P.hbox || !root.classList.contains('cbx-ph')) return;
		var below = P.hbox.isConnected ? panelAfter(P.hbox) : null, changed = false;
		if (!P.hbox.isConnected || !P.hgrip.isConnected || !below) { log('height handle: that part of the page was rebuilt (' + why + '); installing again'); installPhoneLayout(true); fitBar(true); return; }
		if (below !== P.hgripBelow) {
			if (getComputedStyle(below).position !== 'absolute') return;
			log('height handle: the panel under the player is a new element (' + why + '); marking it');
			if (P.hgripBelow) P.hgripBelow.removeAttribute('data-cbx-below');
			P.hgripBelow = below; changed = true; watchPhoneLayout();
		}
		if (!P.hbox.hasAttribute('data-cbx-hbox')) { P.hbox.setAttribute('data-cbx-hbox', ''); changed = true; }
		if (!below.hasAttribute('data-cbx-below')) { below.setAttribute('data-cbx-below', ''); changed = true; log('height handle: the panel had lost our mark (' + why + '); put back'); }
		// marked and still not under the handle: the stylesheet rule is out-ranked, so say it inline, with priority
		var gb = P.hgrip.getBoundingClientRect().bottom, pt = below.getBoundingClientRect().top;
		if (Math.abs(pt - gb) > 6 && P.hgrip.style.display !== 'none') {
			below.style.setProperty('top', 'calc(var(--cbx-ph) + 16px)', 'important');
			below.style.setProperty('height', 'calc(100% - var(--cbx-ph) - 16px)', 'important');
			below._cbxInline = true; changed = true;
			if (assertPhoneLayout.said !== 1) { assertPhoneLayout.said = 1; log('height handle: the panel sat ' + Math.round(pt - gb) + 'px off the handle; placed inline'); }
		}
		if (changed) { if (P.block) P.block._cbxClips = null; setTimeout(function () { fitBar(true); }, 60); }
	}
	function watchPhoneLayout() {
		if (P.layoutMo) { try { P.layoutMo.disconnect(); } catch (e) {} P.layoutMo = null; }
		if (!window.MutationObserver || !P.hbox || !P.hbox.parentElement) return;
		var queued = false;
		P.layoutMo = new MutationObserver(function () {
			if (queued) return; queued = true;
			requestAnimationFrame(function () { queued = false; assertPhoneLayout('page change'); });
		});
		try {
			P.layoutMo.observe(P.hbox.parentElement, { childList: true });
			if (P.hgripBelow) P.layoutMo.observe(P.hgripBelow, { attributes: true, attributeFilter: ['style', 'class', 'data-cbx-below'] });
			P.layoutMo.observe(P.hbox, { attributes: true, attributeFilter: ['data-cbx-hbox'] });
		} catch (e) {}
	}
	function installPhoneLayout(force) {
		if (!isPhone() || !P.box || !P.box.isConnected) return;
		P.box.setAttribute('data-cbx-box', '');
		if (!force && P.hgrip && P.hgrip.isConnected) return;
		var found = findLayoutBox(); if (!found) return;
		$$('[data-cbx-hbox]').forEach(function (n) { n.removeAttribute('data-cbx-hbox'); });
		$$('[data-cbx-below]').forEach(function (n) { n.removeAttribute('data-cbx-below'); if (n._cbxInline) { n.style.removeProperty('top'); n.style.removeProperty('height'); n._cbxInline = false; } });
		if (P.hgrip) { try { P.hgrip.remove(); } catch (e) {} P.hgrip = null; }
		var hbox = found.box, below = found.below, parent = hbox.parentElement;
		if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
		P.hbox = hbox; hbox.setAttribute('data-cbx-hbox', '');
		P.hboxSite = siteBoxHeight();
		below.setAttribute('data-cbx-below', '');
		var grip = el('div', { id: 'cbx-hgrip', title: 'Drag up/down: picture height. Drag left/right: move the picture. Hold, then move: every direction. Double-tap resets.' }, '<i></i>');
		parent.appendChild(grip); P.hgrip = grip; P.hgripBelow = below;
		watchPhoneLayout();
		log('height handle on ' + hbox.tagName.toLowerCase() + (hbox.id ? '#' + hbox.id : '') + (hbox === P.box ? '' : ' (' + 'above the mount box)') + ', site height ' + P.hboxSite);
		/* One handle, one drag, two axes. Up and down change the height, left and right move the picture,
		 * both at once: there is no mode to leave in order to do the other. Each axis wakes up after a few
		 * pixels of its own, so a straight pull does not nudge the other one. Holding still first still
		 * turns the handle into a joystick for every direction (up and down too, for a zoomed picture).
		 * Everything is applied once per frame, not once per touch event. */
		var x0 = 0, y0 = 0, lx = 0, ly = 0, h0 = 0, mode = null, holdT = null, taps = 0, downAt = 0;
		var axY = false, axX = false, wantH = 0, frame = 0, hApplied = 0;
		var paint = function () {
			frame = 0;
			if (mode !== 'drag') return;
			if (axY && wantH !== hApplied) {
				hApplied = wantH;
				S.phoneHeight = P.hboxSite && Math.abs(wantH - P.hboxSite) < 8 ? 0 : wantH;
				document.documentElement.style.setProperty('--cbx-ph', (S.phoneHeight || P.hboxSite || wantH) + 'px');
				// the box changed, so did what the site hides at the sides: measure once, here, and reuse it
				fitInsets();
				if (xfLive) xfLive = measureFit();
			}
			if (axX || (axY && xfLive)) { if (!xfLive) beginLive(); clampPan(xfLive); queueLive(); }
		};
		var end = function () {
			clearTimeout(holdT);
			if (frame) { cancelAnimationFrame(frame); frame = 0; paint(); }
			grip.classList.remove('cbx-dragging', 'cbx-joy', 'cbx-press');
			if (mode === 'drag') { if (axY) save(); endLive(); applyPhoneHeight(); }
			if (mode === 'joy') { endLive(); applyTransform(); }
			if (mode === 'wait' && Date.now() - downAt < 300) {
				taps++; setTimeout(function () { taps = 0; }, 350);
				if (taps === 2) { S.phoneHeight = 0; save(); xform.panX = xform.panY = 0; applyPhoneHeight(); toast('Picture height reset'); }
			}
			mode = null;
		};
		grip.addEventListener('pointerdown', function (e) {
			e.preventDefault(); e.stopPropagation();
			x0 = lx = e.clientX; y0 = ly = e.clientY; downAt = Date.now(); mode = 'wait'; axX = axY = false;
			h0 = hApplied = S.phoneHeight || P.hboxSite || phoneNaturalHeight();
			grip.classList.add('cbx-press'); try { grip.setPointerCapture(e.pointerId); } catch (err) {}
			clearTimeout(holdT);
			holdT = setTimeout(function () {
				if (mode !== 'wait') return;
				var L = panLimitsFor(measureFit(), xform.zoom, true);
				if (L.x < 1 && L.y < 1) { mode = 'dead'; grip.classList.remove('cbx-press'); toast('The whole picture is in view — zoom in, then hold here to look around', 3500); return; }
				mode = 'joy'; grip.classList.add('cbx-joy'); beginLive();
				try { if (navigator.vibrate) navigator.vibrate(12); } catch (err) {}
			}, 380);
		});
		grip.addEventListener('pointermove', function (e) {
			if (!mode || mode === 'dead') return;
			if (mode === 'wait') {
				if (Math.abs(e.clientX - x0) < 8 && Math.abs(e.clientY - y0) < 8) return;
				clearTimeout(holdT); mode = 'drag'; grip.classList.add('cbx-dragging'); lx = e.clientX;
			}
			if (mode === 'drag') {
				// each axis joins in after 10px of its own; from there it follows the finger one to one
				if (!axY && Math.abs(e.clientY - y0) >= 10) { axY = true; y0 += e.clientY > y0 ? 10 : -10; }
				if (!axX && Math.abs(e.clientX - x0) >= 10) { axX = true; lx = x0 + (e.clientX > x0 ? 10 : -10); }
				if (axY) wantH = Math.round(Math.max(120, Math.min(innerHeight - 240, h0 + (e.clientY - y0))));
				if (axX) { xform.panX = (xform.panX || 0) + (e.clientX - lx); lx = e.clientX; }
				if (!frame) frame = requestAnimationFrame(paint);
				return;
			}
			// joystick: the picture follows the finger in every direction, step by step from the clamped position
			xform.panX = (xform.panX || 0) + (e.clientX - lx); xform.panY = (xform.panY || 0) + (e.clientY - ly);
			lx = e.clientX; ly = e.clientY;
			clampPan(xfLive);
			queueLive();
		});
		['pointerup', 'pointercancel'].forEach(function (ev) { grip.addEventListener(ev, end); });
		grip.addEventListener('contextmenu', function (e) { e.preventDefault(); });
		applyPhoneHeight();
		try { if (!localStorage.getItem('cbx-hint-grip')) { localStorage.setItem('cbx-hint-grip', '1'); setTimeout(function () { toast('Handle under the player: up/down resizes, left/right moves the picture', 6000); }, 2500); } } catch (e) {}
	}
	function applyPhoneHeight() {
		var root = document.documentElement;
		var portrait = matchMedia('(orientation: portrait)').matches, on = !!P.hgrip && P.hgrip.isConnected && portrait && !root.classList.contains('cbx-land');
		// nothing of the site's is changed until the handle is dragged: without a height of ours the box is
		// pinned to exactly what the site gives it (measured fresh), which only makes room for the handle
		if (on && !S.phoneHeight && !(P.hgrip && P.hgrip.classList.contains('cbx-dragging'))) P.hboxSite = siteBoxHeight() || P.hboxSite || phoneNaturalHeight();
		root.classList.toggle('cbx-ph', on);
		if (on) root.style.setProperty('--cbx-ph', (S.phoneHeight || P.hboxSite || phoneNaturalHeight()) + 'px'); else root.style.removeProperty('--cbx-ph');
		if (P.hgrip) P.hgrip.style.display = on ? '' : 'none';
		applyTransform();
		clearTimeout(applyPhoneHeight.t); applyPhoneHeight.t = setTimeout(fitBar, 80);
	}
	function removePhoneLayout() {
		if (P.hgrip) { try { P.hgrip.remove(); } catch (e) {} P.hgrip = null; }
		if (P.layoutMo) { try { P.layoutMo.disconnect(); } catch (e) {} P.layoutMo = null; }
		if (P.hgripBelow) { P.hgripBelow.removeAttribute('data-cbx-below'); if (P.hgripBelow._cbxInline) { P.hgripBelow.style.removeProperty('top'); P.hgripBelow.style.removeProperty('height'); P.hgripBelow._cbxInline = false; } P.hgripBelow = null; }
		if (P.hbox) { P.hbox.removeAttribute('data-cbx-hbox'); P.hbox = null; }
		if (document.documentElement.classList.contains('cbx-land')) exitFill('forget');
		if (P.box) P.box.removeAttribute('data-cbx-box');
		document.documentElement.classList.remove('cbx-ph');
		document.documentElement.style.removeProperty('--cbx-ph');
	}
	// rotate to landscape: real fullscreen when the browser allows it (it needs a recent tap), else a
	// full-viewport layout of the same box; rotating back undoes either
	function landscapeMode(on) {
		if (on) {
			var t = P.box;
			if (t && canElementFs() && t.requestFullscreen && !document.fullscreenElement) {
				var pr = t.requestFullscreen();
				if (pr && pr.then) pr.then(function () { P.autoFs = true; }, function () { enterFill(true); });
				else enterFill(true);
			} else enterFill(true);
		} else {
			// only what the rotation itself entered: a fill the viewer asked for with the button stays
			if (P.fillAuto) exitFill(false);
			if (P.autoFs && document.fullscreenElement) { try { document.exitFullscreen(); } catch (e) {} }
			P.autoFs = false;
		}
	}
	function watchRotation() {
		var mq = matchMedia('(orientation: landscape)');
		var onChange = function () {
			applyPhoneHeight();
			if (!isPhone() || !S.rotateFullscreen || !P.block || !P.block.isConnected) { if (document.documentElement.classList.contains('cbx-land')) landscapeMode(false); return; }
			landscapeMode(mq.matches);
		};
		try { if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange); } catch (e) {}
	}

	/* ---- player mode (off / site / deep) ---- */

	/* ---- floating player pill (beside the gear): Deep ⇄ Site ---- */
	function playerIsDeep() { return wantDeep() && !P.forcedSite; }
	var REW_SVG = '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>';
	function updatePlayerToggle() {
		if (!dockEl) return;
		var deep = playerIsDeep(), nat = deep && P.native, on = deep || siteDvrActive();
		// one look everywhere: rewind is on, or it is the plain site player. Which engine holds
		// the window is automatic and lives in the Rewind tab note, not on the pill.
		dockEl.innerHTML = '<i class="cbx-dot cbx-dot-' + (nat ? 'nat' : on ? 'on' : 'off') + '">' + REW_SVG + '</i>' + (nat ? 'Native' : on ? 'Rewind' : 'Site');
		dockEl.title = nat ? 'This browser has no Media Source support, so rewind is limited to what the native player allows (needs iOS 17.1+ / Safari 17.1+)'
			: on ? 'Rewind is on — click for the plain site player (P)' : 'Plain site player — click for rewind (P)';
		dockEl.setAttribute('aria-pressed', on ? 'true' : 'false');
	}
	// P: rewind on this page, or the plain site player
	function togglePlayer() {
		if (!S.siteDvr) { S.siteDvr = true; save(); refreshPanel(); }
		else if (!P.forcedSite) { backToSitePlayer(); return; }
		P.forcedSite = false; P.suspended = false; P.attempt = 0;
		removeDvr(); playerTick(); updatePlayerToggle();
		toast('Rewind on');
	}

	/* ---- site quality menu helpers ---- */
	function siteMenuOpen() { return $$(QUALITY_OPT).some(function (n) { var r = n.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); }
	function closeSiteMenu(btn, done) {
		var attempts = 0;
		(function step() {
			if (!siteMenuOpen() || attempts >= 3) { document.documentElement.classList.remove('cep-quiet-menu'); if (done) done(); return; }
			attempts++;
			try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true })); } catch (e) {}
			if (attempts === 1) clickHard(btn);
			else ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) {
				var Ctor = /pointer/.test(type) && window.PointerEvent ? PointerEvent : MouseEvent;
				try { document.body.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, view: window, clientX: 2, clientY: 2, button: 0, pointerId: 1, pointerType: 'mouse', isPrimary: true })); } catch (e) {}
			});
			setTimeout(step, 350);
		})();
	}

	function playerTick() {
		if (!roomName()) { if (P.block) removeDvr(); placeDock(); return; }
		guardSiteSeeks();
		if (P.block && (!P.box || !P.box.isConnected)) { log('player box replaced; rebuilding'); removeDvr(); }
		if (P.block && P.block.classList.contains('cbx-site') !== siteDvrActive()) { log('player flavor changed; rebuilding'); removeDvr(); }
		if (P.block && !P.busy && P.block.classList.contains('cbx-adopt') !== (!siteDvrActive() && adoptActive())) { log('player engine changed; rebuilding'); removeDvr(); }
		// the site swapped its <video> under an adopted engine: let go of the old one, the new one is adopted below
		if (P.adopted && (!A.v || !A.v.isConnected)) { log('adopt: the site replaced its video'); releaseAdopt('the element is gone', null, false); removeBlock(); }
		var hadBlock = !!P.block;
		ensureBlock();
		// this pass runs after every burst of page changes (several times a second on a busy room). What it
		// measures only changes when the layout does, and the ResizeObserver reports that at once; here it is
		// a safety net, so it runs at most every couple of seconds.
		var nowT = Date.now();
		if (P.block && !document.hidden && (!hadBlock || nowT - (playerTick.measuredAt || 0) > 2000)) {
			playerTick.measuredAt = nowT;
			tagSiteControls();
			fitBar();
			if (!P.hgrip || !P.hgrip.isConnected) installPhoneLayout(); else { assertPhoneLayout('routine check'); healBoxHeight('routine check'); }
		}
		if (adoptActive()) installAdopt(); else installDvr();
		if (!document.hidden) updateBar();
		placeDock();
	}

	/* ================================================================== *
	 * auto quality
	 *
	 * Two paths. With deep rewind on, the stream runs through our own
	 * hls.js instance, so we just pick the level. Otherwise we drive the
	 * site's own quality menu and re-check periodically, because the
	 * player drops back to Auto after buffering stalls and PiP.
	 * ================================================================== */

	var QUALITY_BTN = '[data-testid="video-quality-btn"]';
	var QUALITY_OPT = '[data-testid="quality-option"]';
	var qualityTimer = null, qualityBusy = false, qualityNote = 'not applied yet';

	function parseQualityLabel(node) {
		var label = (node.textContent || '').trim().toLowerCase().replace(/\s+/g, '');
		var h = 0, fps = 0, m;
		if ((m = /^(\d{3,4})p(\d{2,3})?/.exec(label))) { h = +m[1]; fps = +(m[2] || 0); }
		else if ((m = /^(\d{3,4})x(\d{3,4})(?:@?(\d{2,3}))?/.exec(label))) { h = Math.min(+m[1], +m[2]); fps = +(m[3] || 0); }
		else if ((m = /^([248])k(\d{2,3})?/.exec(label))) { h = { 2: 1440, 4: 2160, 8: 4320 }[+m[1]]; fps = +(m[2] || 0); }
		else return null;
		return { node: node, label: label, height: h, fps: fps, score: h * 1000 + fps };
	}

	function clickHard(node) {
		var r = node.getBoundingClientRect();
		var x = r.left + r.width / 2, y = r.top + r.height / 2;
		['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) {
			var Ctor = /pointer/.test(type) && window.PointerEvent ? PointerEvent : MouseEvent;
			try {
				node.dispatchEvent(new Ctor(type, {
					bubbles: true, cancelable: true, view: window,
					clientX: x, clientY: y, button: 0, pointerId: 1, pointerType: 'mouse', isPrimary: true
				}));
			} catch (e) {}
		});
	}

	function bestLevelFrom(list, getHeight) {
		var cap = S.qualityCap || Infinity;
		var eligible = list.filter(function (o) { return getHeight(o) <= cap; });
		var pool = eligible.length ? eligible : list;
		return pool.sort(function (a, b) { return getHeight(b) - getHeight(a); })[0] || null;
	}

	function applyQualityViaHls() {
		if (P.video && P.eng) { qualityNote = P.armed ? 'deep rewind: pinned' : 'deep rewind: auto until armed'; return true; }
		var v = $('#cbx-watch video');
		if (!v || !v._cbxHls || !v._cbxHls.levels || !v._cbxHls.levels.length) return false;
		if (v.classList.contains('cbx-dvr')) {
			// already pinned at load time; re-picking here would flush the buffer
			qualityNote = 'locked by deep rewind';
			return true;
		}
		var h = v._cbxHls;
		var levels = h.levels.map(function (l, i) { return { i: i, height: l.height || 0 }; });
		var pick = bestLevelFrom(levels, function (l) { return l.height; });
		if (!pick) return false;
		setHlsLevel(h, pick.i);
		qualityNote = 'our player, locked to ' + (pick.height || '?') + 'p';
		return true;
	}

	function applyQualityViaMenu() {
		if (qualityBusy) return;
		if (S.siteDvr && Date.now() < siteDvr.quietUntil) { qualityNote = 'waiting for the site player to settle'; return; }
		var btn = $(QUALITY_BTN);
		if (!btn) { qualityNote = 'quality button not on the page'; return; }

		qualityBusy = true;
		document.documentElement.classList.add('cep-quiet-menu');
		clickHard(btn);

		setTimeout(function () {
			var opts = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean);
			if (!opts.length) {
				qualityNote = 'no numeric qualities offered';
				closeSiteMenu(btn, function () { qualityBusy = false; });
				return;
			}
			var pick = bestLevelFrom(opts, function (o) { return o.height; });
			// Clicking the label that is already selected does nothing, and the
			// player quietly decodes a lower rendition after PiP or a stall.
			// Bouncing through Auto first makes it reload the rendition.
			var v = siteVideo();
			var isSel = function (n) { return n.style.color || n.getAttribute('aria-selected') === 'true' || n.getAttribute('aria-checked') === 'true'; };
			var decodedLow = v && v.videoHeight && v.videoHeight < pick.height * 0.9;
			var auto = $$(QUALITY_OPT).filter(function (n) { return /^auto$/i.test((n.textContent || '').trim()); })[0];
			var bounce = decodedLow && isSel(pick.node) && auto && Date.now() - lastQualityBounce > 60000;
			var finish = function () {
				setTimeout(function () {
					closeSiteMenu(btn, function () { qualityBusy = false; refreshPanel(); });
				}, 400);
			};
			try {
				if (bounce) {
					lastQualityBounce = Date.now();
					clickHard(auto);
					qualityNote = 'reset to Auto, then ' + pick.label;
					setTimeout(function () {
						try { clickHard(btn); } catch (e) {}
						setTimeout(function () {
							var again = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean)
								.filter(function (o) { return o.label === pick.label; })[0];
							if (again) clickHard(again.node);
							finish();
						}, 600);
					}, 400);
					return;
				}
				clickHard(pick.node);
				qualityNote = 'set to ' + pick.label;
			} catch (e) { log('quality menu', e); }
			finish();
		}, 700);
	}
	var lastQualityBounce = 0;

	// switching with loadLevel keeps everything already buffered; currentLevel
	// flushes it, which is exactly what wiped the rewind window in 1.9.3
	function setHlsLevel(h, i) {
		h.autoLevelCapping = -1;
		h.loadLevel = i;
		h.nextLevel = i;
	}

	function levelLabel(l) {
		var fps = l.frameRate || (l.attrs && parseFloat(l.attrs['FRAME-RATE'])) || 0;
		var kb = l.bitrate ? Math.round(l.bitrate / 1000) : 0;
		return (l.height || '?') + 'p' + (fps && Math.round(fps) !== 30 ? Math.round(fps) : '') +
			(kb ? ' · ' + (kb >= 1000 ? (kb / 1000).toFixed(1) + ' Mb' : kb + ' kb') : '');
	}
	function levelFps(l) { return l.frameRate || (l.attrs && parseFloat(l.attrs['FRAME-RATE'])) || 0; }

	function hlsLevelOptions(h) {
		return h.levels.map(function (l, i) {
			return { value: 'h' + i, label: levelLabel(l), height: l.height || 0, fps: levelFps(l), bitrate: l.bitrate || 0, i: i };
		}).sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps) || (b.bitrate - a.bitrate); });
	}

	var siteQualityCache = { at: 0, opts: [] };

	function readSiteQualities(done) {
		var btn = $(QUALITY_BTN);
		if (!btn) { done([]); return; }
		if (Date.now() - siteQualityCache.at < 60000 && siteQualityCache.opts.length) { done(siteQualityCache.opts); return; }
		document.documentElement.classList.add('cep-quiet-menu');
		clickHard(btn);
		setTimeout(function () {
			var opts = $$(QUALITY_OPT).map(function (n) {
				var q = parseQualityLabel(n);
				return q ? { value: 's' + q.label, label: q.label, height: q.height, fps: q.fps } : null;
			}).filter(Boolean).sort(function (a, b) { return (b.height - a.height) || (b.fps - a.fps); });
			siteQualityCache = { at: Date.now(), opts: opts };
			closeSiteMenu(btn, function () { done(opts); });
		}, 500);
	}

	// the bar's quality label: what is decoding now, or the engine's pinned track
	function paintQualityBtn(span, v) {
		if (!span) return;
		var label;
		if (v === P.video && P.eng) { var cur = P.eng.active(); label = P.eng.isAuto() ? 'Auto' + (cur ? ' · ' + cur.label.replace(/\s.*$/, '') : '') : (cur ? cur.label.replace(/\s.*$/, '') : '…'); }
		else label = v.videoHeight ? v.videoHeight + 'p' : '…';
		if (span.textContent !== label) span.textContent = label;
	}
	function pickQuality(value) {
		var v = activeVideo();
		var h = v && v._cbxHls;
		if (!value) return;
		if (v === P.video && P.eng) {
			if (value === 'auto') { P.eng.auto(); toast('Quality: auto'); return; }
			var id = parseInt(value.slice(1), 10);
			var t = P.eng.tracks().filter(function (x) { return x.id === id; })[0];
			if (t) { pinTrack(t); toast('Quality: ' + t.label + ' — buffer kept'); }
			return;
		}
		if (h && value === 'auto') { h.autoLevelCapping = -1; h.loadLevel = -1; h.nextLevel = -1; toast('Quality: auto'); return; }
		if (h && value.charAt(0) === 'h') {
			var i = parseInt(value.slice(1), 10);
			setHlsLevel(h, i);
			toast('Quality: ' + levelLabel(h.levels[i]) + (v.classList.contains('cbx-dvr') ? ' — buffer kept' : ''));
			log('quality set to level', i, 'via loadLevel');
			return;
		}
		if (value.charAt(0) === 's') {
			var label = value.slice(1);
			var btn = $(QUALITY_BTN);
			if (!btn) { toast('No quality menu on this player'); return; }
			document.documentElement.classList.add('cep-quiet-menu');
			clickHard(btn);
			setTimeout(function () {
				var hit = $$(QUALITY_OPT).filter(function (n) { var q = parseQualityLabel(n); return q && q.label === label; })[0];
				if (hit) { clickHard(hit); toast('Quality: ' + label); }
				setTimeout(function () { closeSiteMenu(btn); }, 250);
			}, 500);
		}
	}

	function cycleQuality() {
		var v = activeVideo();
		if (v === P.video && P.eng) {
			var tl = P.eng.tracks(), tc = P.eng.active();
			var ti = tl.findIndex(function (t) { return tc && t.id === tc.id; });
			var tn = tl[(ti + 1 + tl.length) % tl.length];
			if (tn) { pinTrack(tn); toast('Quality: ' + tn.label); }
			return;
		}
		var h = v && v._cbxHls;
		if (h && h.levels && h.levels.length > 1) {
			var order = hlsLevelOptions(h);
			var cur = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel;
			var at = order.findIndex(function (o) { return o.i === cur; });
			var next = order[(at + 1 + order.length) % order.length];
			pickQuality(next.value);
			return;
		}
		var btn = $(QUALITY_BTN);
		if (btn) { clickHard(btn); toast('Pick a quality from the menu'); return; }
		toast('No quality options available here');
	}

	function applyQuality() {
		if (!S.autoQuality || !roomName()) return;
		if (applyQualityViaHls()) return;
		// the site player is about to be hidden and dropped to 240p anyway;
		// driving its menu now only makes it flash open on load
		if (wantDeep() && !P.suspended) { qualityNote = 'deep rewind pending'; return; }
		applyQualityViaMenu();
	}

	function startQualityWatchdog() {
		clearInterval(qualityTimer);
		if (!S.autoQuality) return;
		if (!startQualityWatchdog._pip) {
			startQualityWatchdog._pip = true;
			['enterpictureinpicture', 'leavepictureinpicture'].forEach(function (ev) {
				document.addEventListener(ev, function () {
					if (S.autoQuality) setTimeout(function () { qualityBusy = false; applyQuality(); }, ev === 'leavepictureinpicture' ? 800 : 1200);
				}, true);
			});
		}
		qualityTimer = setInterval(function () {
			if (document.hidden || !roomName() || Date.now() < errorHold) return;
			if (P.video && P.armed) return; // deep player: rendition is pinned through hls.js already
			var v = activeVideo();
			if (!v || !v.videoHeight) return;
			var want = S.qualityCap || 0;
			// only nudge when the decoded picture is clearly below what we asked for
			if (want && v.videoHeight < want * 0.9) applyQuality();
			else if (!want && v.videoHeight < 700) applyQuality();
		}, 30000);
	}

	/* ================================================================== *
	 * keyboard shortcuts + touch gestures
	 * ================================================================== */

	function inTextField() {
		var a = document.activeElement;
		// a field inside a component's shadow root: the page only sees the host as focused
		for (var i = 0; a && a.shadowRoot && a.shadowRoot.activeElement && i < 5; i++) a = a.shadowRoot.activeElement;
		if (!a || a === document.body) return false;
		// isContentEditable is inherited, so a caret anywhere inside an editable host counts already; the
		// role and the site's own marker cover a chat box that is built some other way
		if (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName)) return true;
		try { return !!(a.closest && a.closest('[contenteditable=""],[contenteditable="true"],[contenteditable="plaintext-only"],[role="textbox"],[role="searchbox"],[role="combobox"],[data-testid="chat-input"]')); } catch (e) { return false; }
	}

	var KEY_HELP = '← → 10s · J L 30s · K / space pause · M mute · ↑ ↓ volume · Home start · End / 0 live · > 2× · < ½× · A loop · B bookmark · [ ] jump bookmarks · I stats · Q quality · P rewind/site player · S frame · D save buffer · R record · ? help';

	function installKeys() {
		document.addEventListener('keydown', function (e) {
			if (!S.keyShortcuts || !roomName() || inTextField() || e.ctrlKey || e.metaKey || e.altKey) return;
			var k = e.key;
			var handled = true;
			switch (k) {
				case 'ArrowLeft': seekBack(10); break;
				case 'ArrowRight': seekForward(10); break;
				case 'j': case 'J': seekBack(30); break;
				case 'l': case 'L': seekForward(30); break;
				case 'k': case 'K': case ' ': togglePause(); break;
				case 'm': case 'M': toggleMute(); break;
				case 'ArrowUp': case 'ArrowDown': { var vv = activeVideo(); if (!vv) { handled = false; break; } userHasInteracted = true; vv.muted = false; vv.volume = Math.max(0, Math.min(1, Math.round((vv.volume + (k === 'ArrowUp' ? 0.05 : -0.05)) * 100) / 100)); updateBar(); toast('Volume ' + Math.round(vv.volume * 100) + '%', 900); break; }
				case 'Home': seekToStart(); break;
				case 'End': case '0': goLive(); break;
				case '>': toggleCatchUp(); break;
				case '<': toggleSlow(); break;
				case 'a': case 'A': cycleLoop(); break;
				case 'b': case 'B': addBookmark(); break;
				case '[': jumpBookmark(-1); break;
				case ']': jumpBookmark(1); break;
				case 'i': case 'I': toggleStats(); break;
				case 'q': case 'Q': cycleQuality(); break;
				case 'p': case 'P': togglePlayer(); break;
				case 's': case 'S': snapshotFrame(); break;
				case 'd': case 'D': if (S.clipSave) saveClip(); else handled = false; break;
				case 'r': case 'R': toggleRecording(); break;
				case '?': toast(KEY_HELP, 7000); break;
				default: handled = false;
			}
			if (handled) { e.preventDefault(); e.stopPropagation(); }
		}, true);
	}


	/* ================================================================== *
	 * watch without joining chat
	 * ================================================================== */

	// a room in its own window: the stream with the browser's controls and sound, nothing else
	function openTheatre(user) {
		var w = Math.min(1280, Math.round(screen.availWidth * 0.8)), h = Math.round(w * 9 / 16) + 52;
		var url = location.origin + '/?cbx-watch=' + encodeURIComponent(user) + '#cbx-watch=' + encodeURIComponent(user);
		var win = null;
		try { win = wantsTouchLayout() ? window.open(url, 'cbx-theatre-' + user) : window.open(url, 'cbx-theatre-' + user, 'popup=1,width=' + w + ',height=' + h); } catch (e) {}
		if (!win) toast('The browser blocked the popup — allow popups for chaturbate.com');
	}
	function openWatchOnly(user, popup) {
		user = user || roomName();
		if (!user) { toast('You are not in a room'); return; }
		closeWatchOnly();
		if (popup) applySiteCSS();

		// plain divs, the title row first: the old <header> after the <video> was at the mercy of the site's
		// own header rules (it ended up on top, fixed, and partly outside the screen)
		var box = el('div', { id: 'cbx-watch' },
			'<div class="cbx-ov-top"><b>' + esc(user) + '</b><span class="cbx-note">Stream only — chat is not connected</span>' +
			'<button type="button" id="cbx-watch-close" class="cbx-ov-x" aria-label="Close">&times;</button></div>' +
			'<video playsinline webkit-playsinline autoplay controls></video>');
		document.body.appendChild(box);
		document.documentElement.style.overflow = 'hidden';
		vpUse(true);
		if (!popup) backPush('watch', function () { closeWatchOnly('back'); });

		$$('video').forEach(function (v) { if (!v.closest('#cbx-watch')) v.muted = true; });

		var v = $('video', box);
		$('#cbx-watch-close', box).addEventListener('click', function () { if (popup) { try { window.close(); } catch (e) {} } closeWatchOnly(); });
		if (popup) { $('.cbx-note', box).textContent = 'Theatre window — Esc closes'; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { try { window.close(); } catch (err) {} } }); }

		var seq = 0, hidAt = 0, lastT = -1, stuck = 0, userPaused = false, offline = false, offTicks = 0;
		var start = function () {
			var mine = ++seq; stuck = 0; lastT = -1;
			try { if (v._cbxHls) v._cbxHls.destroy(); } catch (e) {}
			v._cbxHls = null;
			hlsFor(user).then(function (url) {
				if (mine !== seq || !box.isConnected) return;
				if (!url) { if (!offline) toast(user + ' is offline'); offline = true; return; }
				offline = false;
				// long back buffer here, because this player is ours
				return attachStream(v, url, { backBufferLength: wantsTouchLayout() ? 120 : 600, liveDurationInfinity: true, onFatal: function () { if (mine === seq) stuck = 99; } }).then(function () {
					if (mine !== seq) return;
					var p = v.play(); if (p && p.catch) p.catch(function () {});
				});
			}).catch(function () { if (mine === seq) toast('Could not load the stream'); });
		};
		// a window that froze in the background (phones) or lost its stream starts again by itself
		v.addEventListener('pause', function () { if (!document.hidden) setTimeout(function () { if (v.paused && !document.hidden) userPaused = true; }, 400); });
		v.addEventListener('play', function () { userPaused = false; });
		box._wd = setInterval(function () {
			if (!box.isConnected) { clearInterval(box._wd); return; }
			if (document.hidden || userPaused || v.seeking) return;
			if (offline) { if (++offTicks % 15 === 0) start(); return; }     // an offline room is asked again once a minute
			var t = v.currentTime; stuck = Math.abs(t - lastT) > 0.1 ? 0 : stuck + 1; lastT = t;
			if (stuck >= 4) { log('watch: not moving; starting again'); start(); }
		}, 4000);
		box._vis = function () {
			if (document.hidden) { hidAt = Date.now(); return; }
			var away = hidAt ? Date.now() - hidAt : 0; hidAt = 0;
			if (!box.isConnected || userPaused) return;
			if (wantsTouchLayout() && away > 20000) start();
			else { var p = v.play(); if (p && p.catch) p.catch(function () {}); }
		};
		document.addEventListener('visibilitychange', box._vis);
		start();
	}

	function closeWatchOnly(how) {
		var box = $('#cbx-watch');
		if (!box) return;
		vpUse(false);
		if (how !== 'back') backPop('watch');
		var v = $('video', box);
		try { if (v && v._cbxHls) v._cbxHls.destroy(); } catch (e) {}
		clearInterval(box._wd);
		if (box._vis) document.removeEventListener('visibilitychange', box._vis);
		box.remove();
		document.documentElement.style.overflow = '';
	}

	/* ================================================================== *
	 * bio info
	 * ================================================================== */

	var bioFor = null;

	function renderBioInfo() {
		var user = roomName();
		if (!S.bioInfo || !user) { var o = $('#cbx-bio'); if (o) o.remove(); return; }
		if (bioFor === user) return; // one attempt per room, whatever the outcome
		bioFor = user;

		fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' })
			.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
			.then(function (d) {
				if (roomName() !== user) return;
				var bits = [];
				if (d.country) bits.push(['Country', d.country + (d.cc ? ' (' + String(d.cc).toUpperCase() + ')' : '')]);
				if (d.region) bits.push(['Region', d.region]);
				if (d.room_status) {
					bits.push(['Status', d.room_status]);
					if (String(d.room_status).toLowerCase() === 'public') recordSeen(user);
				}
				if (d.seconds_online != null) bits.push(['Online for', fmtTime(d.seconds_online)]);
				else if (d.online_for) bits.push(['Online for', d.online_for]);
				if (d.last_online_f) bits.push(['Last seen', d.last_online_f]);
				if (d.satisfaction_score != null) bits.push(['Satisfaction', Math.round(d.satisfaction_score) + '%']);
				if (d.performer_has_fanclub != null) bits.push(['Fan club', d.performer_has_fanclub ? 'yes' : 'no']);
				if (d.has_schedule != null) bits.push(['Schedule', d.has_schedule ? 'published' : 'none']);
				if (String(d.room_status).toLowerCase() === 'offline' && d.room_title) bits.push(['Last subject', d.room_title]);
				if (d.allow_private_shows != null) {
					if (!d.allow_private_shows) bits.push(['Private shows', 'off']);
					else {
						if (d.private_show_price != null) bits.push(['Private show', d.private_show_price + ' tk/min' +
							(d.private_min_minutes ? ', ' + d.private_min_minutes + ' min minimum' : '')]);
						if (d.premium_private_price) bits.push(['Premium private', d.premium_private_price + ' tk/min' +
							(d.premium_private_min_minutes ? ', ' + d.premium_private_min_minutes + ' min minimum' : '')]);
						if (d.spy_private_show_price != null) {
							var spy = d.spy_private_show_price ? d.spy_private_show_price + ' tk/min' : 'free';
							if (d.fan_club_spy_private_show_price != null && d.fan_club_spy_private_show_price !== d.spy_private_show_price)
								spy += ' (fan club ' + (d.fan_club_spy_private_show_price ? d.fan_club_spy_private_show_price + ' tk/min' : 'free') + ')';
							bits.push(['Spy on privates', spy]);
						}
						if (d.allow_show_recordings != null) bits.push(['Privates recorded', d.allow_show_recordings ? 'yes' : 'no']);
					}
				}
				if (!bits.length) return;
				paintBio(bits);

				// joined date and fan club price live on the bio endpoint
				fetch('/api/biocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' })
					.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
					.then(function (b) {
						if (roomName() !== user || !b) return;
						var more = [];
						if (b.joined) more.push(['Joined', b.joined]);
						if (d.performer_has_fanclub && b.fan_club_cost != null) more.push(['Fan club price', b.fan_club_cost + ' tk / month']);
						if (more.length) paintBio(bits.concat(more));
					}).catch(function () {});
			})
			.catch(function (e) { log('bio', e); });

		function paintBio(bits) {
			var old = $('#cbx-bio'); if (old) old.remove();
			var strip = el('div', { id: 'cbx-bio' }, bits.map(function (b) {
				return '<div><dt>' + esc(b[0]) + '</dt><dd>' + esc(b[1]) + '</dd></div>';
			}).join(''));
			var anchor = $('[data-testid="room-bio-tab-contents"]') || $('.BioContents') ||
				($('video') && ($('video').closest(MAIN_PLAYER_SEL) || $('video').parentElement));
			if (anchor) anchor.insertBefore ? anchor.insertBefore(strip, anchor.firstChild) : anchor.appendChild(strip);
		}
	}

	/* ================================================================== *
	 * chat translate
	 * ================================================================== */

	var LANGS = [
		['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'],
		['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['ru', 'Russian'], ['tr', 'Turkish'],
		['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['ja', 'Japanese'], ['ko', 'Korean'],
		['zh-CN', 'Chinese'], ['ro', 'Romanian'], ['uk', 'Ukrainian']
	];

	var trCache = {}, trQueue = [], trBusy = false, trFails = 0, trBackoff = 0, trNextAt = 0, trTimer = null, trTold = false;
	var TR_QUEUE_MAX = 40;
	var TR_GAP = 1000;      // between two requests; it was 220ms, which a busy room turned into four or five requests a second

	function translate(text, target) {
		target = target || S.translateTo;
		var ck = target + '\u0000' + text;
		if (trCache[ck]) return Promise.resolve(trCache[ck]);
		var url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' +
			encodeURIComponent(target) + '&dt=t&q=' + encodeURIComponent(text);
		return fetch(url).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
			var out = (d[0] || []).map(function (p) { return p[0]; }).join('');
			var src = d[2];
			var res = { text: out, src: src };
			trCache[ck] = res;
			trFails = 0;
			return res;
		});
	}

	var TR_SEP = '\n';
	function applyTranslation(job, res) {
		if (!res || (res.src && res.src.split('-')[0] === S.translateTo.split('-')[0])) return;
		if (!res.text || res.text.trim().toLowerCase() === job.text.trim().toLowerCase()) return;
		if (!job.node.isConnected) return;
		job.node.appendChild(el('span', { 'class': 'cbx-tr' }, esc(res.text)));
	}
	// up to 8 short messages go in one request, split back on line breaks;
	// when the line count does not match, each one is retried on its own
	function pumpQueue() {
		if (!S.translateChat) { trQueue = []; return; }
		if (trBusy || !trQueue.length) return;
		// the pace holds for every way in: a new chat line calls this too, and must not jump a back-off
		var wait = trNextAt - Date.now();
		if (wait > 0) { if (!trTimer) trTimer = setTimeout(function () { trTimer = null; pumpQueue(); }, wait + 20); return; }
		// a line that has scrolled out of the chat is not worth a request
		trQueue = trQueue.filter(function (j) { return j.node.isConnected; });
		if (!trQueue.length) return;
		trBusy = true;
		var jobs = [], chars = 0;
		while (trQueue.length && jobs.length < 8 && chars < 1400) {
			var j = trQueue[0];
			if ((j.solo || j.text.indexOf(TR_SEP) !== -1) && jobs.length) break;
			jobs.push(trQueue.shift()); chars += j.text.length;
			if (j.solo) break;
		}
		var single = jobs.length === 1;
		var req = single ? translate(jobs[0].text) : translate(jobs.map(function (j) { return j.text.replace(/\s*\n\s*/g, ' '); }).join(TR_SEP));
		req.then(function (res) {
			if (trBackoff) log('translate: answering again');
			trBackoff = 0; trTold = false;
			if (single) { applyTranslation(jobs[0], res); return; }
			var parts = (res.text || '').split(TR_SEP);
			if (parts.length !== jobs.length) { trQueue = jobs.concat(trQueue); jobs.forEach(function (j) { j.solo = true; }); return; }
			jobs.forEach(function (j, i) { applyTranslation(j, { text: parts[i], src: res.src }); });
		}).catch(function (e) {
			// It used to switch translation off for good after five failures in a row, which a throttled
			// service or a dropped connection produced in about a second. Now it waits longer each time
			// (2, 4, 8, 16, 32s) and carries on by itself when the service answers again. The lines get one
			// more try; one that fails twice is let go, so a single bad line can never block the queue.
			trFails++;
			trBackoff = Math.min(32000, (trBackoff || 1000) * 2);
			var again = jobs.filter(function (j) { j.tries = (j.tries || 0) + 1; return j.tries < 2 && j.node.isConnected; });
			trQueue = again.concat(trQueue).slice(0, TR_QUEUE_MAX);
			log('translate failed (' + (e && e.message || e) + '); next try in ' + trBackoff / 1000 + 's');
			if (trFails >= 3 && !trTold) { trTold = true; toast('Translation is being held back by the service — it will carry on by itself', 4000); }
		}).then(function () {
			trBusy = false;
			trNextAt = Date.now() + (trBackoff || TR_GAP);
			pumpQueue();
		});
	}

	// one pass over new chat messages: mute, highlight, tip markers, then translation
	var chatFirstPass = true;
	function chatUserOf(m) {
		var u = m.querySelector('[data-testid="chat-message-username"],.username,a[href^="/"]');
		return u ? (u.textContent || '').trim().replace(/[:\s]+$/, '').toLowerCase() : '';
	}
	function keywordRe() {
		var src = (S.chatKeywords || '').split(/[,\n]/).map(function (k) { return k.trim(); }).filter(Boolean);
		var key = src.join('|');
		if (keywordRe._key !== key) { keywordRe._key = key; keywordRe._re = key ? new RegExp(src.map(function (k) { return k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|'), 'i') : null; }
		return keywordRe._re;
	}
	function rescanChat() {
		$$('div[data-testid="chat-message"]').forEach(function (m) { m._cbxSeen = false; m.classList.remove('cbx-muted', 'cbx-hl'); });
		chatFirstPass = true; processChat();
	}
	function processChat() {
		var muted = S.chatMuted || [], re = keywordRe(), first = chatFirstPass; chatFirstPass = false;
		$$('div[data-testid="chat-message"]').forEach(function (m) {
			if (m._cbxSeen) return;
			m._cbxSeen = true;
			var user = chatUserOf(m);
			if (user && muted.indexOf(user) !== -1) { m.classList.add('cbx-muted'); return; }
			var body = m.querySelector('.message,[data-testid="chat-message-text"]') || m;
			if (re && re.test(body.textContent || '')) m.classList.add('cbx-hl');
			var dv = dvrVideo();
			if (!first && S.tipMarks && dv && (P.armed || siteDvrActive()) && m.querySelector('.isTip')) { P.tips = P.tips || []; P.tips.push(dv.currentTime); }
		});
		translateNewMessages();
	}
	function translateNewMessages() {
		if (!S.translateChat) return;
		/* The newest lines go to the front of the queue, and when the queue is over its limit it is the
		 * oldest that are let go. It used to be the other way round: a line was ticked off as handled first
		 * and queued second, oldest first, and a full queue simply refused newcomers. In a busy room, or
		 * while the service held us back, every line that arrived was then marked as done without ever being
		 * translated, for good, while the queue kept working through lines that had long scrolled away. */
		var msgs = $$('div[data-testid="chat-message"]'), fresh = [];
		for (var i = msgs.length - 1; i >= 0; i--) {
			var m = msgs[i];
			if (m._cbxTr || m.classList.contains('cbx-muted')) continue;
			m._cbxTr = true;
			// more new lines than the queue holds (a page that opens on a full chat): the oldest of them are
			// let go here and now. Left unticked they would come back on the next pass as "new" and jump
			// ahead of lines that really are newer.
			if (fresh.length >= TR_QUEUE_MAX) continue;
			var body = m.querySelector('.message,[data-testid="chat-message-text"]') || m;
			var text = (body.textContent || '').trim();
			if (!text || text.length > 400) continue;
			fresh.push({ node: m, text: text });
		}
		if (fresh.length) {
			trQueue = fresh.concat(trQueue);
			if (trQueue.length > TR_QUEUE_MAX) trQueue.length = TR_QUEUE_MAX;      // the tail is the oldest: those are the ones to give up on
		}
		pumpQueue();
	}

	/* ================================================================== *
	 * appearance
	 * ================================================================== */

	function applyDark() {
		if (!S.forceDark) return;
		var apply = function () {
			if (document.body) document.body.classList.add('darkmode');
			if (document.documentElement) document.documentElement.classList.add('darkmode');
		};
		apply();
		try {
			var parts = location.hostname.split('.');
			var base = parts.length <= 2 ? location.hostname : parts.slice(-2).join('.');
			var exp = 'expires=Sun, 1 Jan 9999 00:00:00 UTC; path=/';
			document.cookie = 'theme_name=darkmode; ' + exp;
			document.cookie = 'theme_name=darkmode; ' + exp + '; domain=.' + base;
		} catch (e) {}
		onReady(apply);
	}

	var RULES = {
		hideAds: '.ad,.vote-banner,.promoteRoomLink,#ad_unit,.ad-unit,.ad-container,.ad-wrapper,' +
			'[id^="ad_"],[id^="google_ads_"],[id^="div-gpt-ad"],[class*="ad-slot"],[class*="ads-container"],' +
			'[class*="banner-ad"],iframe[src*="doubleclick"],iframe[src*="googlesyndication"],' +
			'iframe[src*="adnxs"],iframe[src*="adsafeprotected"]',
		hideSocials: '#social-media-icons,.social_medias,.BioContents tr.smContainer:has(td[data-testid="bio-tab-social-media-value"])',
		hideMerch: 'a#merch,#cbswag,li:has(> a#cbswag),a[href*="/cbswag"]',
		hideSurveys: '.dismissibleMessage:has(a[href*="surveymonkey"]),.feedback_notice,a.feedbackLink,[data-testid="suggest-other-payment-methods-btn"],.upgrade_footer_text:has(a[href*="surveymonkey"])',
		hidePlayerLogo: '#VideoPanel .cbLogo,.cbLogo',
		hideBadges: '.RoomCardThumbnail__labelContainer,.thumbnail_label',
		chatHideNotices: 'div[data-testid="chat-message"]:has(.roomNotice:not(.isTip):not(.titleChange):not(.bright-background))',
		chatHideSubject: 'div[data-testid="chat-message"]:has(.roomNotice.titleChange)',
		chatHideTips: 'div[data-testid="chat-message"]:has(.isTip)',
		chatHideGreys: 'div[data-testid="chat-message"]:has(.defaultUser)'
	};

	// cards carry a gender span; the nav tabs and their links are hidden too,
	// so turning a gender off removes its button from the site as well
	var GENDER_RULES = {
		hideGenderF: '.RoomCard:has(span.genderf),.roomCard:has(span.genderf),' +
			'[data-testid="gender-nav-f"],a[href="/female-cams/"],li:has(>a[href="/female-cams/"])',
		hideGenderM: '.RoomCard:has(span.genderm),.roomCard:has(span.genderm),' +
			'[data-testid="gender-nav-m"],a[href="/male-cams/"],li:has(>a[href="/male-cams/"])',
		hideGenderC: '.RoomCard:has(span.genderc),.roomCard:has(span.genderc),' +
			'[data-testid="gender-nav-c"],a[href="/couple-cams/"],li:has(>a[href="/couple-cams/"])',
		hideGenderT: '.RoomCard:has(span.genders),.roomCard:has(span.genders),' +
			'[data-testid="gender-nav-t"],[data-testid="gender-nav-s"],a[href="/trans-cams/"],li:has(>a[href="/trans-cams/"])'
	};

	var EXTRA_RULES = {
		tightMargins:
			'.main-content-wrapper:has(.top-section.roomPage){padding-left:0!important;padding-right:0!important}' +
			'.BaseRoomContents{margin-left:0!important;margin-top:0!important}' +
			'#theatermode-root{margin-right:0!important}',
		cleanProfile:
			'tr:not(.smContainer):not(.psContainer) .contentText *{position:static!important;background:none!important;' +
			'text-shadow:none!important;letter-spacing:normal!important;animation:none!important;transform:none!important}' +
			'tr:not(.smContainer):not(.psContainer) .contentText img{max-width:100%!important;height:auto!important}' +
			'tr:not(.smContainer):not(.psContainer) .contentText *[style*="position: absolute"]{position:static!important}' +
			'div[data-testid="bio-tab-about-me-value"] *,div[data-testid="bio-tab-wish-list-value"] *{overflow:hidden!important;font-size:inherit!important}'
	};

	// the older server-rendered pages (fan club, supporter, followers, account
	// forms) ignore the darkmode class; give them the same palette by hand
	var LEGACY_DARK_CSS =
		'body.darkmode #main .content_body,body.darkmode #main .form_body,body.darkmode .fanclub_container .frame,body.darkmode #supporter_upgrade_content .frame,' +
		'body.darkmode .accounts_list_followers .room_list_room{background:#202c39!important;border-color:#2d3e50!important;color:#f0f0f0!important}' +
		'body.darkmode #main .content_body h1,body.darkmode #main h1,body.darkmode #main h2,body.darkmode .fanclub_desc,body.darkmode .fanclub_benefits td,body.darkmode .fanclub_benefits th,' +
		'body.darkmode #supporter_upgrade_content td,body.darkmode #supporter_upgrade_content .price,body.darkmode .fieldset_main th label,' +
		'body.darkmode .accounts_list_followers .room_list_room :is(.age,.sub-info,.subject){color:#f0f0f0!important}' +
		'body.darkmode #main .content_body a,body.darkmode #main .form_body a{color:#68b5f0!important}' +
		'body.darkmode .fanclub_benefits .blue_background,body.darkmode #supporter_upgrade_content .blue_background{background:#0d2a4a!important}' +
		'body.darkmode .fanclub_benefits tr:not(.blue_background):not(:first-child),body.darkmode #supporter_upgrade_content tr:not(.blue_background):not(:first-child){background:#1a2531!important}' +
		'body.darkmode .support_message .highlight{background:#002249!important;border-color:#004795!important}' +
		'body.darkmode form input[type=text],body.darkmode form input[type=password],body.darkmode form textarea,body.darkmode form select{background:#17202a!important;color:#f0f0f0!important;border-color:#2d3e50!important}';

	var CARD_CSS =
		'.RoomCard,.roomCard,.FollowedDropdown__room{position:relative}' +
		'.cbx-tools{position:absolute;top:4px;right:4px;z-index:5;display:flex;gap:3px;opacity:0;transition:opacity .12s ease}' +
		'.RoomCard:hover .cbx-tools,.roomCard:hover .cbx-tools,.FollowedDropdown__room:hover .cbx-tools,.cbx-touch .cbx-tools{opacity:1}' +
		'.FollowedDropdown__room .cbx-tools{top:2px;right:2px}.FollowedDropdown__room .cbx-note-strip{display:none}' +
		'.cbx-tools button{width:22px;height:22px;padding:0;border:0;border-radius:5px;background:rgba(15,18,21,.78);color:#fff;font:12px/1 system-ui,sans-serif;cursor:pointer}' +
		'.cbx-tools button:hover{background:#f67300}' +
		'.cbx-tools button[data-act="more"]{width:26px;font-size:15px;letter-spacing:-1px}' +
		'.cbx-touch .cbx-tools{gap:5px;top:6px;right:6px}.cbx-touch .cbx-tools button{width:34px;height:34px;font-size:15px;background:rgba(15,18,21,.88)}' +
		'.cbx-touch .cbx-tools button[data-act="more"]{width:38px;font-size:20px}' +
		'.cbx-note-strip{position:absolute;left:0;right:0;bottom:0;z-index:4;background:rgba(15,18,21,.82);color:#ffd9b0;font:11px/1.3 system-ui,sans-serif;padding:3px 6px;pointer-events:none}' +
		'#cbx-duration{position:absolute;left:8px;top:8px;z-index:6;background:rgba(0,0,0,.6);color:#fff;border-radius:5px;padding:3px 7px;font:12px/1 system-ui,sans-serif;pointer-events:none}' +
		/* ---- player block: in flow, replaces the site's video slot ---- */
		'#cbx-block{position:absolute;inset:0;z-index:2147483000;overflow:hidden;visibility:visible!important;display:flex;flex-direction:column;background:#000;color:#e8ebed;font:13px/1 system-ui,sans-serif}' +
		'#cbx-shell{position:relative;flex:1 1 auto;min-height:0;background:#000;overflow:hidden;visibility:visible!important}' +
		'#cbx-block *{visibility:visible}' +
		'#cbx-stats{position:absolute;left:8px;top:8px;z-index:5;background:rgba(0,0,0,.6);color:#e8ebed;font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;padding:5px 7px;border-radius:5px;pointer-events:none;white-space:nowrap}' +
		'#cbx-shell video.cbx-dvr{position:absolute;inset:0;width:100%;height:100%;background:#000;object-fit:contain;display:block;backface-visibility:hidden}' +
		'#cbx-shell{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}' +
		'html.cbx-touch #cbx-shell{touch-action:pan-y}#cbx-block.cbx-zoomed #cbx-shell{touch-action:none;cursor:grab}#cbx-block.cbx-panx #cbx-shell{touch-action:pan-y;cursor:grab}#cbx-block.cbx-zoomed #cbx-shell:active,#cbx-block.cbx-panx #cbx-shell:active{cursor:grabbing}' +
		'#cbx-block:fullscreen{position:fixed;inset:0}' +
		'#cbx-block #cbx-bar{transition:opacity .3s ease}' +
		'#cbx-block.cbx-idle:fullscreen #cbx-bar{opacity:0;pointer-events:none}' +
		'#cbx-block.cbx-idle:fullscreen{cursor:none}:fullscreen #cbx-block.cbx-idle{cursor:none}' +
		'#cbx-block:is(.cbx-site,.cbx-adopt),#cbx-block:is(.cbx-site,.cbx-adopt) #cbx-shell{background:transparent}' +
		/* warming up: the site's picture shows through until our player has a frame */
		'#cbx-block.cbx-warm,#cbx-block.cbx-warm #cbx-shell{background:transparent}#cbx-block.cbx-warm #cbx-shell video.cbx-dvr{opacity:0}' +
		/* overlay: shell fills the box, the bar floats over its bottom edge */
		'#cbx-block.cbx-overlay #cbx-shell{position:absolute;inset:0}' +
		'#cbx-block.cbx-overlay #cbx-bar{position:absolute;left:0;right:0;bottom:0;border-top:0;padding-top:10px;background:linear-gradient(to top,rgba(0,0,0,.82) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,0) 100%)}' +
		'#cbx-bigplay{position:absolute;left:50%;top:50%;width:68px;height:68px;margin:-34px 0 0 -34px;border:0;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;display:none;align-items:center;justify-content:center;cursor:pointer;z-index:4;transition:background .15s ease,transform .15s ease}' +
		'#cbx-bigplay:hover{background:rgba(0,0,0,.75);transform:scale(1.06)}#cbx-bigplay svg{width:32px;height:32px;margin-left:4px}#cbx-bigplay.cbx-bp-pause svg{margin-left:0}#cbx-block.cbx-paused #cbx-bigplay{display:flex}' +
		/* touch: the centre button is the pause button, so it shows whenever the controls do */
		'html.cbx-touch #cbx-block:not(.cbx-idle):not(.cbx-site):not(.cbx-warm) #cbx-bigplay{display:flex}' +
		'#cbx-bar button{transition:background .12s ease}' +
		'@media (prefers-reduced-motion:reduce){#cbx-bigplay,#cbx-bar button,#cbx-scrub,#cbx-vol,#cbx-zoomside{transition:none}}' +
		/* phones: the picture height variable wins over the site's inline layout; the panel under the box follows */
		'html.cbx-ph [data-cbx-hbox]{height:var(--cbx-ph)!important}' +
		'html.cbx-ph [data-cbx-below]{top:calc(var(--cbx-ph) + 16px)!important;height:calc(100% - var(--cbx-ph) - 16px)!important}' +
		'#cbx-hgrip{position:absolute;left:0;right:0;top:var(--cbx-ph,0px);height:16px;z-index:2147482990;display:flex;align-items:center;justify-content:center;background:#14171a;border-top:1px solid #2a3138;touch-action:none;cursor:ns-resize;user-select:none}' +
		'#cbx-hgrip i{width:44px;height:4px;border-radius:2px;background:#5a646c;transition:width .15s ease,height .15s ease,background .15s ease}#cbx-hgrip.cbx-dragging i{background:#f67300}' +
		'#cbx-hgrip{-webkit-touch-callout:none;-webkit-user-select:none}#cbx-hgrip.cbx-press i{background:#8b969e}#cbx-hgrip.cbx-joy{background:#1c2126}#cbx-hgrip.cbx-joy i{width:16px;height:16px;border-radius:8px;background:#fff;box-shadow:0 0 0 4px rgba(255,255,255,.18)}' +
		/* rotated: the box fills the viewport when real fullscreen was not allowed */
		'html.cbx-land [data-cbx-box]{position:fixed!important;left:var(--cbx-vx,0px)!important;top:var(--cbx-vy,0px)!important;right:auto!important;bottom:auto!important;width:var(--cbx-vw,100%)!important;height:var(--cbx-vh,100%)!important;max-width:none!important;max-height:none!important;margin:0!important;z-index:2147482900!important;background:#000}html.cbx-land body{overflow:hidden!important}' +
		'#cbx-recbadge{position:absolute;top:8px;right:8px;z-index:5;padding:3px 8px;border-radius:5px;background:rgba(0,0,0,.6);color:#e33;font:12px/1.2 system-ui,sans-serif;pointer-events:none;animation:cbx-blink 1.2s ease-in-out infinite}#cbx-recbadge span{color:#fff}' +
		/* zoom sits top-left and shrinks on short pictures so it never reaches the bar */
		'#cbx-zoomside{position:absolute;left:6px;top:8px;bottom:84px;z-index:4;display:flex;flex-direction:column;align-items:center;gap:4px;opacity:.5;transition:opacity .25s ease}' +
		'#cbx-zoomside:hover,#cbx-zoomside:active{opacity:1}#cbx-block.cbx-idle #cbx-zoomside{opacity:0;pointer-events:none}' +
		'#cbx-zoomside button{appearance:none;border:0;border-radius:4px;background:rgba(0,0,0,.45);color:#fff;font:11px/1 system-ui,sans-serif;font-weight:600;padding:4px 6px;cursor:pointer;min-width:34px}#cbx-zoomside button:hover{background:rgba(0,0,0,.7)}' +
		'#cbx-zoomside .cbx-zwrap{width:24px;flex:0 1 120px;min-height:44px;max-height:120px;display:flex;align-items:center;justify-content:center}' +
		'#cbx-zoomside input{writing-mode:vertical-lr;direction:rtl;width:28px;height:100%;margin:0;accent-color:#fff;cursor:pointer;touch-action:none;opacity:0;position:relative;z-index:1}' +
		/* the system draws a vertical range its own way on every platform (a grey knob on iOS); the input stays for the touch handling and is invisible, the slider you see is drawn here */
		'#cbx-zoomside{opacity:.38}#cbx-zoomside .cbx-zwrap{position:relative;width:28px}' +
		'#cbx-zoomside .cbx-zwrap::before{content:"";position:absolute;left:50%;top:8px;bottom:8px;width:3px;margin-left:-1.5px;border-radius:2px;background:rgba(255,255,255,.34)}' +
		'#cbx-zoomside .cbx-zfill{position:absolute;left:50%;bottom:8px;width:3px;margin-left:-1.5px;border-radius:2px;background:#fff;height:calc((100% - 16px) * var(--cbx-z,0))}' +
		'#cbx-zoomside .cbx-zknob{position:absolute;left:50%;bottom:calc(8px + (100% - 16px) * var(--cbx-z,0) - 7px);width:14px;height:14px;margin-left:-7px;border-radius:7px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.55)}' +
		'#cbx-zoomside button{border:1px solid rgba(255,255,255,.26);border-radius:7px;background:rgba(40,44,48,.55);padding:5px 7px;letter-spacing:.2px}#cbx-zoomside button:hover{background:rgba(255,255,255,.22)}' +
		'#cbx-block.cbx-overlay.cbx-idle #cbx-bar{opacity:0;pointer-events:none}' +
		'#cbx-bar{flex:none;position:relative;display:flex;flex-direction:column;gap:0;padding:2px 6px 3px;background:#14171a;border-top:1px solid #2a3138}' +
		'#cbx-thumb-prev{display:none;position:absolute;bottom:100%;margin-bottom:6px;transform:translateX(-50%);border:2px solid #f67300;border-radius:6px;overflow:hidden;background:#000;box-shadow:0 4px 16px rgba(0,0,0,.6);pointer-events:none;z-index:6}' +
		'#cbx-thumb-prev canvas{display:block;width:160px;height:auto}#cbx-thumb-prev span{display:block;text-align:center;font:11px/1 system-ui,sans-serif;color:#fff;padding:4px 8px;white-space:nowrap}' +
		'.cbx-row{display:flex!important;align-items:center;gap:6px;flex-wrap:nowrap;min-width:0;width:100%;box-sizing:border-box}' +
		'#cbx-scrub .cbx-marks i{position:absolute;top:-4px;bottom:-4px;width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 2px #000}' +
		'#cbx-scrub .cbx-marks i.cbx-bm{background:#3ad07a;top:-5px;bottom:-1px}#cbx-scrub .cbx-marks i.cbx-tip{background:#ffd23f;top:100%;bottom:auto;height:4px;margin-top:2px;width:3px}' +
		'.cbx-row-scrub{gap:0;min-height:14px;padding-top:14px}' +
		'.cbx-time{font-size:11px;color:#cfd6db;flex:0 0 auto!important;width:auto!important;min-width:0!important;display:inline!important;white-space:nowrap}' +
		'#cbx-scrub-at{position:absolute;bottom:100%;left:100%;margin-bottom:5px;transform:translateX(-90%);font-size:10px;line-height:1;padding:2px 5px;border-radius:3px;' +
		'background:rgba(0,0,0,.65);color:#fff;pointer-events:none;transition:none}' +
		'#cbx-bar.cbx-behind-live #cbx-scrub-at{background:#f67300}' +
		/* the panel's .cbx-row span{flex:1} must never reach the bar: only the spacer is flexible here */
		'#cbx-bar .cbx-row > span{flex:0 0 auto;min-width:0}#cbx-bar .cbx-row > span.cbx-grow{flex:1 1 auto}.cbx-volwrap{display:inline-flex;align-items:center;gap:2px}' +
		'#cbx-bar:not(.cbx-behind-live) #cbx-scrub-at{display:none}' +
		'#cbx-bar button{position:relative;flex:0 0 auto;min-width:32px;min-height:32px;border:0;border-radius:6px;background:transparent;color:#fff;' +
		'font:12px/1 system-ui,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent;display:flex;align-items:center;justify-content:center;gap:3px;padding:0 6px}' +
		'#cbx-bar button:hover{background:rgba(255,255,255,.12);color:#fff}' +
		'#cbx-bar button svg{width:15px;height:15px;flex:none}#cbx-bar button b{font-weight:600;font-size:12px}' +
		/* the LIVE pill: red at the edge, grey once you are behind; click goes back to live */
		'#cbx-bar .cbx-live{background:#d92c2c;color:#fff;padding:0 9px;font-weight:600;letter-spacing:.3px;font-size:11px;border-radius:4px}#cbx-bar .cbx-live i{width:6px;height:6px;border-radius:50%;background:currentColor;display:inline-block}' +
		'#cbx-bar.cbx-behind-live .cbx-live{background:rgba(255,255,255,.16);color:#d7dde2}#cbx-bar.cbx-behind-live .cbx-live:hover{background:rgba(255,255,255,.28)}' +
		'#cbx-bar button.cbx-dot::after{content:"";position:absolute;top:5px;right:5px;width:6px;height:6px;border-radius:50%;background:#f67300}' +
		'#cbx-bar button.cbx-on{background:rgba(255,255,255,.16)}' +
		'#cbx-rec{gap:5px}#cbx-rec-txt:empty{display:none}#cbx-rec.cbx-rec-on{color:#ff6b6b}' +
		'#cbx-rec-dot{width:12px;height:12px;border-radius:50%;border:2px solid currentColor;box-sizing:border-box;flex:none}' +
		'#cbx-rec.cbx-rec-on #cbx-rec-dot{background:#e33;border-color:#e33;animation:cbx-blink 1.2s ease-in-out infinite}@keyframes cbx-blink{50%{opacity:.35}}' +
		'#cbx-scrub{flex:1 1 0%!important;width:auto!important;min-width:40px;position:relative;height:4px;border-radius:2px;margin:6px 8px;background:rgba(255,255,255,.14);cursor:pointer;touch-action:none;user-select:none;display:block!important;transition:height .12s ease,margin .12s ease}' +
		/* a 4px line is not a click target: a 12px halo above and below catches the pointer */
		'#cbx-scrub::before{content:"";position:absolute;left:0;right:0;top:-12px;bottom:-12px}' +
		'#cbx-bar:hover #cbx-scrub,#cbx-scrub.cbx-hovering{height:6px;margin-top:5px;margin-bottom:5px}' +
		'#cbx-scrub .cbx-hover{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;background:#fff;opacity:0;pointer-events:none;transition:opacity .1s}' +
		'#cbx-scrub.cbx-hovering .cbx-hover{opacity:.9}' +
		'#cbx-scrub .cbx-held{position:absolute;left:0;top:0;bottom:0;width:100%}#cbx-scrub .cbx-held i{position:absolute;top:0;bottom:0;border-radius:3px;background:rgba(255,255,255,.28)}' +
		'#cbx-scrub .cbx-fill{position:absolute;left:0;top:0;bottom:0;border-radius:3px;background:#f67300;width:0}' +
		'#cbx-scrub .cbx-thumb{position:absolute;top:50%;left:100%;width:13px;height:13px;margin:-6.5px 0 0 -6.5px;border-radius:50%;background:#fff;border:2px solid #f67300;box-shadow:0 1px 4px rgba(0,0,0,.5);box-sizing:border-box}' +
		/* volume: folded away next to the mute button, slides out on hover (or a tap on touch) */
		/* volume: our own track/fill/thumb (like the timeline), folded to nothing beside the mute
		   button and unfolding to the right on hover, or after a tap on touch */
		'#cbx-vol{position:relative;display:block;width:0;height:4px;margin:0;border-radius:2px;background:rgba(255,255,255,.3);cursor:pointer;touch-action:none;user-select:none;opacity:0;visibility:hidden;transition:width .18s ease,opacity .18s ease,margin .18s ease,visibility 0s linear .18s}' +
		'.cbx-volwrap:hover #cbx-vol,.cbx-volwrap.cbx-open #cbx-vol,#cbx-vol.cbx-held{width:80px;opacity:1;visibility:visible;margin:0 12px 0 8px;transition:width .18s ease,opacity .18s ease,margin .18s ease,visibility 0s}' +
		'#cbx-vol::before{content:"";position:absolute;left:-8px;right:-8px;top:-12px;bottom:-12px}' +
		'#cbx-vol .cbx-vfill{position:absolute;left:0;top:0;bottom:0;width:100%;border-radius:2px;background:#f67300;pointer-events:none}' +
		'#cbx-vol .cbx-vthumb{position:absolute;top:50%;left:100%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.6);pointer-events:none}' +
		'#cbx-bar .cbx-qbtn{width:auto;min-width:0;padding:0 9px;font-size:12px;font-weight:600;color:#e8ebed}#cbx-bar.cbx-ours .cbx-qbtn{box-shadow:inset 0 0 0 1px rgba(246,115,0,.55)}' +
		'#cbx-behind{margin-left:auto;color:#cfd6db;font-size:11px;white-space:nowrap;padding:0 4px}' +
		'#cbx-bar.cbx-warming button[data-act="back"],#cbx-bar.cbx-warming button[data-act="fwd"],#cbx-bar.cbx-warming #cbx-scrub{opacity:.3;pointer-events:none}' +
		'#cbx-bar.cbx-warming #cbx-behind{color:#f6a25e}' +
		/* site controls out of the way while our block is up */
		/* the site's controls and quality menu are hidden while our block is up; nothing that contains the picture itself is ever touched */
		'html.cep-block .theater-video-controls:not(:has(video)){opacity:0!important;pointer-events:none!important}' +
		'html.cep-block [data-cbx-sitectl]{opacity:0!important;pointer-events:none!important}' +
		'html.cep-block [data-cbx-box] :is(.vjs-control-bar,[class*="video-controls"],[class*="VideoControls"],[class*="player-controls"],[class*="PlayerControls"],[class*="playerControls"]):not(:has(video)):not(#cbx-block):not(#cbx-block *){opacity:0!important;pointer-events:none!important}' +
		'html.cep-block :is(div,ul,section):has(> [data-testid="quality-option"]):not(:has(video)),html.cep-block :is(div,ul,section):has(> * > [data-testid="quality-option"]):not(:has(video)){visibility:hidden!important}' +
		'html.cep-quiet-menu [data-testid="quality-option"],html.cep-quiet-menu :is(div,ul,section):has(> [data-testid="quality-option"]):not(:has(video)),' +
		'html.cep-quiet-menu :is(div,ul,section):has(> * > [data-testid="quality-option"]):not(:has(video)){visibility:hidden!important}' +
		'html.cep-dvr-on :is(' + MAIN_PLAYER_SEL + ') :is(.vjs-big-play-button,.vjs-loading-spinner){display:none!important}' +
		/* The bar is sized from its own width, never from the window's: the player box can be narrower
		   than the screen, and on a phone the "window" can be wider than what is visible. fitBar() picks
		   the first tier in which the whole row fits, so the last button (fullscreen) is never clipped.
		   compact: snapshot, record, quality and the held-time text move into the gear menu.
		   tiny: the 10s buttons go as well. slim: one row that sits on top of the site's own controls. */
		'#cbx-bar.cbx-t-compact{padding:1px 4px 3px}' +
		'#cbx-bar.cbx-t-compact #cbx-behind{display:none}' +
		/* what does not fit is dropped in a fixed order by fitBar(); record, pause, mute, LIVE, the gear and fullscreen never are */
		'#cbx-bar .cbx-drop{display:none!important}' +
		'#cbx-bar.cbx-t-compact :is(.cbx-qbtn,#cbx-rec.cbx-rec-on){width:auto;padding:0 6px;font-size:11px}' +
		'#cbx-bar.cbx-t-compact .cbx-volwrap.cbx-open #cbx-vol,#cbx-bar.cbx-t-compact #cbx-vol.cbx-held{width:56px;margin:0 8px 0 4px}' +
		'#cbx-bar.cbx-t-compact .cbx-row-btns{flex-wrap:nowrap;justify-content:space-between;gap:2px}' +
		'#cbx-bar.cbx-t-compact button{min-width:0;padding:0;flex:0 0 auto;width:38px;min-height:44px}#cbx-bar.cbx-t-compact button b{display:none}' +
		'#cbx-bar.cbx-t-compact .cbx-live{width:auto;padding:0 6px;margin:0 1px;font-size:10px}' +
		'html.cbx-touch #cbx-bar button{min-height:34px}html.cbx-touch #cbx-bar.cbx-t-compact button{min-height:40px}' +
		/* a phone sets its volume with its own buttons (iOS ignores video.volume outright), and an inline slider that unfolds is what pushed fullscreen off the row */
		'html.cbx-touch #cbx-bar.cbx-t-compact #cbx-vol{display:none!important}' +
		/* slim: play, timeline, LIVE and the gear on one line; everything else belongs to the site's own controls or the gear menu */
		/* filled screen: keep the bar and the zoom column clear of the notch and the home indicator */
		'html.cbx-land #cbx-block.cbx-overlay #cbx-bar{padding-left:max(6px,env(safe-area-inset-left,0px));padding-right:max(6px,env(safe-area-inset-right,0px));padding-bottom:max(3px,env(safe-area-inset-bottom,0px))}' +
		'html.cbx-land #cbx-zoomside{left:max(6px,env(safe-area-inset-left,0px))}' +
		'html.cbx-land :is(#cbx-launcher,#cbx-dock,#cbx-multidock,#cbx-hgrip){display:none!important}' +
		'#cbx-block.cbx-overlay #cbx-bar{left:var(--cbx-il,0px);right:var(--cbx-ir,0px);bottom:var(--cbx-ib,0px)}' +
		'#cbx-zoomside{left:calc(6px + var(--cbx-il,0px));top:calc(8px + var(--cbx-it,0px));bottom:calc(84px + var(--cbx-ib,0px))}' +
		'#cbx-bigplay{margin-left:calc(-34px + (var(--cbx-il,0px) - var(--cbx-ir,0px)) / 2);margin-top:calc(-34px + (var(--cbx-it,0px) - var(--cbx-ib,0px)) / 2)}' +
		'#cbx-stats,#cbx-block #cbx-duration{left:calc(8px + var(--cbx-il,0px));top:calc(8px + var(--cbx-it,0px))}' +
		'#cbx-soundpill{left:calc(50% + (var(--cbx-il,0px) - var(--cbx-ir,0px)) / 2);top:calc(10px + var(--cbx-it,0px))}' +
		/* a timeline has to be findable on a dark picture with a thumb */
		'html.cbx-touch #cbx-scrub{background:rgba(255,255,255,.26)}html.cbx-touch #cbx-scrub .cbx-held i{background:rgba(255,255,255,.42)}' +
		'#cbx-bar,#cbx-bar button,#cbx-bigplay,#cbx-zoomside button,#cbx-soundpill{touch-action:manipulation}' +
		'#cbx-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;background:#000;filter:brightness(.55);pointer-events:none}' +
		'#cbx-soundpill{position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:6;border:0;border-radius:18px;height:36px;padding:0 14px;background:rgba(0,0,0,.72);color:#fff;font:600 13px/1 system-ui,sans-serif;display:inline-flex;align-items:center;gap:6px;cursor:pointer;-webkit-tap-highlight-color:transparent}#cbx-soundpill svg{width:16px;height:16px}' +
		'#cbx-block.cbx-float{position:fixed!important;left:var(--cbx-vx,0px)!important;top:var(--cbx-vy,0px)!important;width:var(--cbx-vw,100%)!important;height:var(--cbx-vh,100%)!important;right:auto!important;bottom:auto!important;z-index:2147482950!important}' +
		'html.cbx-touch #cbx-scrub{height:6px;margin:8px 10px 10px}html.cbx-touch #cbx-scrub .cbx-thumb{width:18px;height:18px;margin:-9px 0 0 -9px}' +
		'html.cbx-touch #cbx-scrub::before{top:-16px;bottom:-16px}' +
		'#cbx-bio{display:flex;flex-wrap:wrap;gap:6px 14px;margin:8px 0;padding:8px 10px;border:1px solid #2a3138;border-radius:8px;' +
		'background:rgba(20,23,26,.6);font:12px/1.4 system-ui,sans-serif;color:#e8ebed}' +
		'#cbx-bio dt{color:#8b969e;font-size:11px;margin:0}#cbx-bio dd{margin:0}' +
		'.cbx-tr{display:block;color:#ffb066;font-size:.92em;padding-left:4px}' +
		'#cbx-hover{position:fixed;right:14px;bottom:14px;width:min(440px,44vw);aspect-ratio:16/9;z-index:2147481000;' +
		'background:#000;border:1px solid #2a3138;border-radius:10px;overflow:hidden;display:none;box-shadow:0 8px 32px rgba(0,0,0,.5)}' +
		'#cbx-hover.cbx-on{display:block}' +
		'.cbx-inline-prev{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:3;' +
		'background:#000;border-radius:inherit;opacity:0;transition:opacity .18s ease}' +
		'.cbx-inline-prev.cbx-ready{opacity:1}' +
		'#cbx-hover video{width:100%;height:100%;object-fit:cover;display:block}' +
		'#cbx-hover figcaption{position:absolute;left:0;right:0;bottom:0;background:rgba(15,18,21,.85);color:#e8ebed;font:12px/1.4 system-ui,sans-serif;padding:5px 9px 6px;display:flex;flex-direction:column;gap:1px}' +
		'#cbx-hover figcaption strong{font-size:13px;font-weight:600}#cbx-hover figcaption b{font-weight:400;color:#cfd6db}' +
		'#cbx-hover figcaption span{color:#8b969e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
		/* pinned to what is visible (see viewportBox), inside the safe areas, every layout property spelled out */
		'#cbx-watch{position:fixed!important;left:var(--cbx-vx,0px)!important;top:var(--cbx-vy,0px)!important;right:auto!important;bottom:auto!important;width:var(--cbx-vw,100%)!important;height:var(--cbx-vh,100%)!important;' +
		'margin:0!important;transform:none!important;z-index:2147481500;background:#0f1215;display:flex!important;flex-direction:column;box-sizing:border-box;overflow:hidden;' +
		'padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)}' +
		'#cbx-watch .cbx-ov-top{position:static!important;display:flex!important;flex:none;align-items:center;gap:10px;width:auto!important;height:auto!important;min-height:52px;margin:0!important;padding:4px 8px 4px 14px;' +
		'box-sizing:border-box;color:#e8ebed;font:14px/1.3 system-ui,sans-serif;border-bottom:1px solid #2a3138;background:#0f1215}' +
		'#cbx-watch .cbx-ov-top b{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
		'#cbx-watch .cbx-ov-top .cbx-note{flex:1 1 0%;min-width:0;color:#8b969e;font-size:12px;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
		'#cbx-watch .cbx-ov-x{flex:none;width:44px;height:44px;min-width:44px;padding:0;border:0;border-radius:22px;background:#1c2126;color:#e8ebed;font:24px/1 system-ui,sans-serif;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
		'#cbx-watch video{flex:1 1 0%;width:100%;min-height:0;background:#000;object-fit:contain}';

	// chat-only rules live apart so chat toggles do not restyle the whole page
	function applyChatCSS() {
		var st = $('#cbx-chat-css');
		if (!st) { st = el('style', { id: 'cbx-chat-css' }); (document.head || document.documentElement).appendChild(st); }
		var css = '.cbx-muted{display:none!important}.cbx-hl{background:rgba(246,115,0,.16)!important;box-shadow:inset 3px 0 0 #f67300}';
		if (S.chatTipsOnly) css += 'div[data-testid="chat-message"]:not(:has(.isTip)){display:none!important}';
		if (S.chatFont) css += 'div[data-testid="chat-message"],div[data-testid="chat-message"] *{font-size:' + S.chatFont + 'px!important}';
		if (st.textContent !== css) st.textContent = css;
	}
	function applySiteCSS() {
		var style = $('#cbx-site-css');
		if (!style) {
			style = el('style', { id: 'cbx-site-css' });
			(document.head || document.documentElement).appendChild(style);
		}
		var hides = [], css = CARD_CSS;
		Object.keys(RULES).forEach(function (k) { if (S[k]) hides.push(RULES[k]); });
		Object.keys(GENDER_RULES).forEach(function (k) { if (S[k]) hides.push(GENDER_RULES[k]); });
		if (S.gridSize) css += '.RoomCardGrid{grid-template-columns:repeat(auto-fill,minmax(' + S.gridSize + 'px,1fr))!important}';
		var more = S.moreGridSize || S.gridSize;
		if (more) css += '.MoreRooms .list{grid-template-columns:repeat(auto-fill,minmax(' + more + 'px,1fr))!important}';
		Object.keys(EXTRA_RULES).forEach(function (k) { if (S[k]) css += EXTRA_RULES[k]; });
		applyChatCSS();
		var custom = jsonGet(HIDE_KEY, []);
		if (custom.length) hides = hides.concat(custom);
		if (hides.length) css = hides.join(',') + '{display:none!important}' + css;
		if (S.forceDark && S.darkLegacy) css += LEGACY_DARK_CSS;
		style.textContent = css;
	}

	/* ================================================================== *
	 * element picker
	 * ================================================================== */

	function selectorFor(node) {
		var parts = [], depth = 0;
		while (node && node.nodeType === 1 && depth < 4) {
			if (node.id && !/^\d/.test(node.id)) { parts.unshift('#' + CSS.escape(node.id)); break; }
			var tid = node.getAttribute('data-testid');
			if (tid) { parts.unshift('[data-testid="' + tid + '"]'); break; }
			var cls = (node.className && typeof node.className === 'string')
				? node.className.trim().split(/\s+/).filter(function (c) { return c && !/^cbx-/.test(c); }).slice(0, 2)
				: [];
			parts.unshift(node.tagName.toLowerCase() + (cls.length ? '.' + cls.map(function (c) { return CSS.escape(c); }).join('.') : ''));
			node = node.parentElement;
			depth++;
		}
		return parts.join(' ');
	}

	var pickerOn = false;

	function togglePicker(on) {
		pickerOn = on;
		document.documentElement.classList.toggle('cbx-picking', on);
		if (on) {
			document.addEventListener('mouseover', pickHover, true);
			document.addEventListener('click', pickClick, true);
		} else {
			document.removeEventListener('mouseover', pickHover, true);
			document.removeEventListener('click', pickClick, true);
			$$('.cbx-pick-hl').forEach(function (n) { n.classList.remove('cbx-pick-hl'); });
		}
	}
	function pickHover(e) {
		$$('.cbx-pick-hl').forEach(function (n) { n.classList.remove('cbx-pick-hl'); });
		if (e.target.closest && e.target.closest('#cbx-panel,#cbx-launcher')) return;
		e.target.classList.add('cbx-pick-hl');
	}
	function pickClick(e) {
		if (e.target.closest && e.target.closest('#cbx-panel,#cbx-launcher')) return;
		e.preventDefault(); e.stopPropagation();
		var sel = selectorFor(e.target);
		if (!sel) return;
		var list = jsonGet(HIDE_KEY, []);
		if (list.indexOf(sel) === -1) list.push(sel);
		jsonSet(HIDE_KEY, list);
		applySiteCSS(); togglePicker(false); refreshPanel();
		toast('Hidden: ' + sel);
	}

	/* ================================================================== *
	 * room cards
	 * ================================================================== */

	// room cards on listing pages plus the rows of the header's Followed dropdown
	var CARD_SEL = '.RoomCard,.roomCard,.FollowedDropdown__room';

	function cardUser(card) {
		var a = card.querySelector('a[href^="/"]');
		if (!a) return null;
		var seg = a.getAttribute('href').split('/').filter(Boolean)[0];
		return isRoomPath(seg) ? seg : null;
	}

	// the flag on a card is a flag-icons span: .thumbnail_flag > .fi.fi-xx
	function cardCountry(card) {
		var f = card.querySelector('.thumbnail_flag .fi, .thumbnail_flag [class*="fi-"]');
		if (!f) return null;
		var m = /(?:^|\s)fi-([a-z]{2})(?:\s|$)/.exec(f.className || '');
		return m ? m[1] : null;
	}
	var ccNames = null;
	function countryName(cc) {
		if (!cc) return '';
		try {
			if (!ccNames && window.Intl && Intl.DisplayNames) ccNames = new Intl.DisplayNames([uiLangResolved() || 'en'], { type: 'region' });
			return (ccNames && ccNames.of(cc.toUpperCase())) || cc.toUpperCase();
		} catch (e) { return cc.toUpperCase(); }
	}
	function hiddenCountries() { return jsonGet(CC_KEY, []); }
	function hideCountry(cc) {
		var l = hiddenCountries(); if (l.indexOf(cc) === -1) l.push(cc);
		jsonSet(CC_KEY, l); decorateCards(); refreshPanel(); toast(countryName(cc) + ' hidden');
	}

	function ensureRandomLink() {
		var old = $('#cbx-random-link');
		if (!S.randomLink) { if (old) old.remove(); return; }
		if (old && old.isConnected) return;
		var nav = $('.HeaderNavBar__links, nav[class*="HeaderNavBar"] ul, .HeaderNavBar');
		if (!nav) return;
		var sib = nav.querySelector('a.HeaderNavBar__link');
		var a = el('a', { id: 'cbx-random-link', href: '#', 'class': sib ? sib.className : 'HeaderNavBar__link', title: 'Open a random live room' },
			'<div class="' + (sib && sib.firstElementChild ? sib.firstElementChild.className : '') + '">random</div>');
		a.addEventListener('click', function (e) {
			e.preventDefault();
			toast('Picking a room…');
			var genders = (S.randomGenders || 'f').split('');
			roomsForGender(genders[Math.floor(Math.random() * genders.length)]).then(function (names) {
				var block = jsonGet(BLOCK_KEY, []);
				names = names.filter(function (u) { return block.indexOf(u) === -1 && u !== roomName(); });
				if (!names.length) { toast('No rooms found'); return; }
				var u = names[Math.floor(Math.random() * names.length)];
				if (S.openNewTab) window.open('/' + u + '/', '_blank'); else location.href = '/' + u + '/';
			}).catch(function () { toast('Could not fetch the room list'); });
		});
		(sib && sib.parentElement === nav ? nav : (sib ? sib.parentElement : nav)).appendChild(a);
	}

	function decorateCards() {
		var block = jsonGet(BLOCK_KEY, []), note = jsonGet(NOTES_KEY, {}), ccs = hiddenCountries();
		var stamp = [block.length, JSON.stringify(note).length, ccs.join(','), S.openNewTab, S.cardWatchBtn, S.cardTools].join('|');
		$$(CARD_SEL).forEach(function (card) {
			var user = cardUser(card);
			if (!user) return;
			if (card._cbxStamp === stamp && card._cbxUser === user && (!S.cardTools || card.querySelector('.cbx-tools'))) return;
			card._cbxStamp = stamp; card._cbxUser = user;

			if (block.indexOf(user) !== -1) { card.style.display = 'none'; return; }
			var cc = ccs.length ? cardCountry(card) : null;
			if (cc && ccs.indexOf(cc) !== -1) { card.style.display = 'none'; return; }
			card.style.display = '';

			if (S.openNewTab) $$('a[href^="/"]', card).forEach(function (a) { a.target = '_blank'; a.rel = 'noopener'; });

			var strip = card.querySelector('.cbx-note-strip');
			if (note[user]) {
				if (!strip) { strip = el('div', { 'class': 'cbx-note-strip' }); card.appendChild(strip); }
				strip.textContent = note[user];
			} else if (strip) strip.remove();

			if (!S.cardTools) { var t = card.querySelector('.cbx-tools'); if (t) t.remove(); return; }
			if (card.querySelector('.cbx-tools')) return;

			var tools = el('div', { 'class': 'cbx-tools' },
				(S.cardWatchBtn ? '<button data-act="watch" title="Watch without chat">&#9654;</button>' : '') +
				'<button data-act="more" title="More" aria-haspopup="true">&#8943;</button>');
			card.appendChild(tools);

			var fire = function (b) {
				var act = b.getAttribute('data-act');
				if (act === 'watch') openWatchOnly(user);
				else if (act === 'more') openDD('card', b, { user: user, cc: cardCountry(card), card: card });
			};
			// the whole card is a link with its own touch handling on phones; swallow
			// our taps at touchstart/touchend so the site never sees them, and act on touchend
			['touchstart', 'touchmove', 'pointerdown', 'mousedown'].forEach(function (ev) {
				tools.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: true });
			});
			tools.addEventListener('touchend', function (e) {
				var b = e.target.closest('button'); if (!b) return;
				e.preventDefault(); e.stopPropagation();
				tools._touched = Date.now(); fire(b);
			});
			tools.addEventListener('click', function (e) {
				var b = e.target.closest('button');
				if (!b) return;
				e.preventDefault(); e.stopPropagation();
				if (Date.now() - (tools._touched || 0) < 700) return; // already handled on touchend
				fire(b);
			});
		});
	}

	// actions for one room, used by the ⋯ menu on cards and the Room menu under the player
	function roomAction(what, user, ctx) {
		ctx = ctx || {};
		if (!user) { toast('You are not in a room'); return; }
		if (what === 'watch') openWatchOnly(user);
		else if (what === 'url') copyStreamUrl();
		else if (what === 'alert') toggleAlertFor(user);
		else if (what === 'note') {
			var n = jsonGet(NOTES_KEY, {});
			var val = prompt('Note for ' + user, n[user] || '');
			if (val === null) return;
			if (val.trim()) n[user] = val.trim(); else delete n[user];
			jsonSet(NOTES_KEY, n); decorateCards();
			toast(val.trim() ? 'Note saved' : 'Note removed');
		} else if (what === 'multi') {
			var m = jsonGet(MULTI_KEY, []);
			if (m.indexOf(user) === -1) m.push(user);
			jsonSet(MULTI_KEY, m); toast(user + ' added — ' + m.length + ' saved');
		} else if (what === 'hide') {
			if (ctx.confirm && !confirm('Hide ' + user + ' from every room list?')) return;
			var l = jsonGet(BLOCK_KEY, []); if (l.indexOf(user) === -1) l.push(user);
			jsonSet(BLOCK_KEY, l);
			if (ctx.card) ctx.card.style.display = 'none';
			decorateCards(); refreshPanel(); toast(user + ' hidden');
		} else if (what === 'country') {
			if (ctx.cc) hideCountry(ctx.cc);
		}
	}

	/* ================================================================== *
	 * hover preview
	 * ================================================================== */

	var hoverBox = null, hoverTimer = null, hoverUser = null, inlineCard = null;

	/* --- preview played inside the thumbnail itself --- */

	function thumbOf(card) {
		var img = card.querySelector('img');
		return (img && img.parentElement) || card;
	}

	function inlinePreviewOn(card, user) {
		if (inlineCard === card) return;
		inlinePreviewOff();
		inlineCard = card;

		var thumb = thumbOf(card);
		if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative';

		var v = el('video', { 'class': 'cbx-inline-prev', muted: '', playsinline: '', 'webkit-playsinline': 'true', autoplay: '' });
		v.muted = S.previewMuted;
		thumb.appendChild(v);

		hlsFor(user).then(function (url) {
			if (inlineCard !== card || !url) return;
			return attachStream(v, url).then(function () {
				var p = v.play(); if (p && p.catch) p.catch(function () {});
				v.classList.add('cbx-ready');
			});
		}).catch(function () {});
	}

	function inlinePreviewOff() {
		if (!inlineCard) return;
		$$('.cbx-inline-prev', inlineCard).forEach(function (v) {
			try { if (v._cbxHls) { v._cbxHls.destroy(); v._cbxHls = null; } v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {}
			v.remove();
		});
		inlineCard = null;
	}

	/* --- preview in a corner box --- */

	function ensureHoverBox() {
		if (hoverBox) return hoverBox;
		hoverBox = el('figure', { id: 'cbx-hover' }, '<video muted playsinline webkit-playsinline autoplay></video><figcaption></figcaption>');
		document.body.appendChild(hoverBox);
		return hoverBox;
	}

	// what the card itself says about the room, for the corner preview
	function cardFacts(card) {
		if (!card) return '';
		var txt = function (sel) { var n = card.querySelector(sel); return n ? (n.textContent || '').trim() : ''; };
		var g = card.querySelector('[data-testid="room-card-gender"],.gender,[class*="gender"]');
		var gm = g && /gender(f|m|c|s|t)\b/.exec(g.className || '');
		var gender = gm ? ({ f: 'F', m: 'M', c: 'Couple', s: 'Trans', t: 'Trans' })[gm[1]] : '';
		var bits = [txt('.age'), gender, txt('.location'), txt('.cams,.viewers,[class*="viewer"]')].filter(Boolean);
		var subject = txt('.subject,.title,[data-testid="room-card-subject"],[class*="subject"]');
		return (bits.length ? '<b>' + esc(bits.join(' · ')) + '</b>' : '') + (subject ? '<span>' + esc(subject) + '</span>' : '');
	}

	function showPreview(user, card) {
		if (hoverUser === user) return;
		hoverUser = user;
		var box = ensureHoverBox(), v = $('video', box);
		$('figcaption', box).innerHTML = '<strong>' + esc(user) + '</strong>' + cardFacts(card);
		box.classList.add('cbx-on');
		v.muted = S.previewMuted;
		hlsFor(user).then(function (url) {
			if (hoverUser !== user || !url) return;
			return attachStream(v, url, { reuse: true }).then(function () {
				var p = v.play(); if (p && p.catch) p.catch(function () {});
			});
		}).catch(function () {});
	}

	function hidePreview() {
		hoverUser = null;
		if (!hoverBox) return;
		hoverBox.classList.remove('cbx-on');
		var v = $('video', hoverBox);
		try {
			if (v._cbxHls && v._cbxHls._reusable) { v._cbxHls.stopLoad(); v._cbxHls.detachMedia(); }
			else if (v._cbxHls) { v._cbxHls.destroy(); v._cbxHls = null; v.pause(); v.removeAttribute('src'); v.load(); }
			else { v.pause(); v.removeAttribute('src'); v.load(); }
		} catch (e) {}
	}

	function startPreview(card, user) {
		if (S.previewInline) inlinePreviewOn(card, user);
		else showPreview(user, card);
	}

	function stopPreview() {
		inlinePreviewOff();
		hidePreview();
	}

	function installHoverPreview() {
		document.addEventListener('mouseover', function (e) {
			if (!S.hoverPreview) return;
			var card = e.target.closest && e.target.closest(CARD_SEL);
			clearTimeout(hoverTimer);
			if (!card) { hoverTimer = setTimeout(stopPreview, 250); return; }
			if (card === inlineCard) return;
			var user = cardUser(card);
			if (!user) return;
			hoverTimer = setTimeout(function () { startPreview(card, user); }, 350);
		}, true);

		document.addEventListener('mouseout', function (e) {
			if (!S.hoverPreview || !inlineCard) return;
			var to = e.relatedTarget;
			if (to && inlineCard.contains(to)) return;
			clearTimeout(hoverTimer);
			hoverTimer = setTimeout(stopPreview, 200);
		}, true);

		// phones: press and hold a card, release or tap away to stop
		var lpTimer = null;
		document.addEventListener('touchstart', function (e) {
			if (!S.hoverPreview) return;
			var card = e.target.closest && e.target.closest(CARD_SEL);
			if (!card) { stopPreview(); return; }
			if (e.target.closest('.cbx-tools')) return;
			var user = cardUser(card);
			if (!user) return;
			lpTimer = setTimeout(function () {
				startPreview(card, user);
			}, 450);
		}, { passive: true });

		['touchend', 'touchmove', 'touchcancel'].forEach(function (ev) {
			document.addEventListener(ev, function () { clearTimeout(lpTimer); }, { passive: true });
		});

		document.addEventListener('click', function (e) {
			if (inlineCard && !e.target.closest('.cbx-inline-prev')) stopPreview();
			if (hoverBox && hoverBox.classList.contains('cbx-on') && !e.target.closest('#cbx-hover')) hidePreview();
		});

		// a playing preview should not block the link underneath
		document.addEventListener('click', function (e) {
			var v = e.target.closest('.cbx-inline-prev');
			if (!v) return;
			var card = v.closest(CARD_SEL);
			var a = card && card.querySelector('a[href^="/"]');
			if (a) { e.preventDefault(); a.click(); }
		}, true);
	}

	/* ================================================================== *
	 * chat rules
	 * ================================================================== */

	function autoAcceptRules() {
		if (!S.autoChatRules) return;
		var btns = $$('.rulesModal button,[data-testid="room-rules-accept"],button');
		for (var i = 0; i < btns.length; i++) {
			var txt = (btns[i].textContent || '').trim().toLowerCase();
			if (/^(i agree|agree|accept)$/.test(txt) && btns[i].offsetParent) {
				try { btns[i].click(); } catch (e) {}
				return;
			}
		}
	}

	/* ================================================================== *
	 * streams
	 * ================================================================== */

	/* Asked by everything that starts a stream: the player, a rebuild after a background tab, previews,
	 * multi cam tiles. Three things it did not do: tell an error page from an answer (a 429 or a 502 came
	 * out as "Unexpected token <"), share a request that is already on its way (a rebuild and a restore
	 * asked twice in the same moment), and hold back after a failure. The player retries on its routine
	 * pass, which runs after every burst of page changes, so a rate-limited address was asked for again
	 * several times a second: the surest way to stay rate-limited. A failure now waits 1.5, 3, 6 ... 30s. */
	var hlsAsk = {}, hlsAskN = 0, HLS_ASK_TIMEOUT = 12000;
	function hlsFor(user) {
		var k = String(user).toLowerCase();
		if (!hlsAsk[k]) { if (++hlsAskN > 300) { hlsAsk = {}; hlsAskN = 1; } hlsAsk[k] = { p: null, fails: 0, until: 0, why: '' }; }
		var st = hlsAsk[k];
		if (st.p) return st.p;
		if (Date.now() < st.until) { var held = new Error('waiting ' + Math.ceil((st.until - Date.now()) / 1000) + 's after ' + st.why); held.cooldown = true; return Promise.reject(held); }
		// Everyone who asks for this room while a request is on its way shares it, so a request that never
		// answers (a phone between wifi and cellular) would hold them all up for as long as the browser cares
		// to wait, which can be minutes. It is given up after 12 seconds and counts as a failure like any other.
		var opts = { credentials: 'include', cache: 'no-store' }, ac = null, timer = null;
		try { if (window.AbortController) { ac = new AbortController(); opts.signal = ac.signal; } } catch (e) { ac = null; }
		var asked = fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', opts);
		var timed = new Promise(function (resolve, reject) {
			timer = setTimeout(function () { try { if (ac) ac.abort(); } catch (e) {} reject(new Error('no answer in ' + Math.round(HLS_ASK_TIMEOUT / 1000) + 's')); }, HLS_ASK_TIMEOUT);
			asked.then(resolve, reject);
		});
		st.p = timed
			.then(function (r) { clearTimeout(timer); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
			.then(function (d) { st.p = null; st.fails = 0; st.until = 0; return d && d.hls_source ? d.hls_source : null; },
				function (e) {
					clearTimeout(timer);
					st.p = null; st.fails++; st.why = (e && e.message) || 'an error';
					st.until = Date.now() + Math.min(30000, 1500 * Math.pow(2, st.fails - 1));
					log('stream address for ' + k + ': ' + st.why + '; asking again in ' + Math.round((st.until - Date.now()) / 1000) + 's at the earliest');
					throw e;
				});
		return st.p;
	}

	function requiredHls() {
		if (typeof window.Hls !== 'undefined' && window.Hls) return window.Hls;
		try { if (typeof Hls !== 'undefined' && Hls) return Hls; } catch (e) {}
		return null;
	}

	// hls.js normally arrives through @require. Some managers install the script even when that
	// download failed (a blocked or unreachable CDN), and then every player of ours was dead. As a
	// second chance the same file is asked for from the page, one mirror after another, pinned to
	// the exact bytes of the npm release. A strict site policy can still refuse all of them; then
	// native playback is what is left, as before.
	var HLS_SRI = 'sha384-9v3HcdYrO3D+OPDTjZ40RXocgE4GtXVCd3/mCS62JsM93JXgI1afJVuwjFvsu6ni';
	var HLS_MIRRORS = [
		'https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js',
		'https://unpkg.com/[email protected]/dist/hls.min.js',
		'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js'
	];
	var hlsLoading = null, hlsGaveUpAt = 0;
	function injectScript(url, integrity) {
		return new Promise(function (resolve, reject) {
			var sc = document.createElement('script'), done = false;
			var finish = function (err) { if (done) return; done = true; clearTimeout(timer); if (err) { try { sc.remove(); } catch (e) {} reject(err); } else resolve(); };
			var timer = setTimeout(function () { finish(new Error('timed out')); }, 10000);
			sc.src = url; sc.async = true; sc.crossOrigin = 'anonymous';
			if (integrity) sc.integrity = integrity;
			sc.onload = function () { finish(null); };
			sc.onerror = function () { finish(new Error('blocked or unreachable')); };
			(document.head || document.documentElement).appendChild(sc);
		});
	}
	function loadHlsJs() {
		var have = requiredHls();
		if (have) return Promise.resolve(have);
		if (hlsLoading) return hlsLoading;
		if (Date.now() - hlsGaveUpAt < 60000) return Promise.reject(new Error('hls.js was not loaded by the userscript manager, and the page refused the mirrors'));
		log('hls.js did not come with the script (@require failed?) — trying the mirrors from the page');
		hlsLoading = HLS_MIRRORS.reduce(function (chain, url) {
			return chain.catch(function () {
				return injectScript(url, HLS_SRI).then(function () {
					var H = requiredHls();
					if (!H) throw new Error('loaded, but no Hls');
					log('hls.js loaded from ' + url.split('/')[2]);
					return H;
				});
			});
		}, Promise.reject(new Error('start'))).catch(function () {
			hlsLoading = null; hlsGaveUpAt = Date.now();
			throw new Error('hls.js was not loaded by the userscript manager, and the page refused the mirrors');
		});
		return hlsLoading;
	}

	// Chaturbate's playlists advertise CAN-BLOCK-RELOAD, so hls.js appends
	// _HLS_msn/_HLS_part to every playlist reload and the edge holds the
	// request until that part exists. On a slow edge that outlives the
	// timeout (the 1.9.7 log: every error was levelLoadTimeOut or
	// audioTrackLoadTimeOut on such a URL), and while a playlist request
	// hangs nothing new gets scheduled. A rewind player does not need the
	// live edge, so ask for the plain playlist and let the edge answer now.
	var plainPlaylistLoader = null;
	function plainLoaderFor(Hls) {
		if (plainPlaylistLoader) return plainPlaylistLoader;
		var Base = Hls.DefaultConfig.loader;
		function PlainLoader(config) { Base.call(this, config); }
		PlainLoader.prototype = Object.create(Base.prototype);
		PlainLoader.prototype.constructor = PlainLoader;
		PlainLoader.prototype.load = function (context, config, callbacks) {
			try {
				if (context && typeof context.url === 'string' && /_HLS_(msn|part|skip)=/.test(context.url)) {
					var u = new URL(context.url);
					['_HLS_msn', '_HLS_part', '_HLS_skip'].forEach(function (k) { u.searchParams.delete(k); });
					context.url = u.toString();
				}
			} catch (e) {}
			return Base.prototype.load.call(this, context, config, callbacks);
		};
		plainPlaylistLoader = PlainLoader;
		return PlainLoader;
	}

	function canUseMse() {
		return !!(window.MediaSource || window.ManagedMediaSource);
	}
	function isDvrVideo(v) { return !!v && (v.classList.contains('cbx-dvr') || !!v._cbxAdopted); }

	function attachStream(video, url, opts) {
		// Chrome answers "maybe" to the HLS mime type but native playback there
		// reports an empty seekable range, so rewinding is impossible. Use
		// hls.js whenever Media Source exists and keep native for real Safari.
		if (canUseMse()) {
			// previews keep their hls.js instance between hovers: swap the source instead of rebuilding
			if (opts && opts.reuse && video._cbxHls && video._cbxHls._reusable) {
				try {
					var rh = video._cbxHls;
					rh.stopLoad(); rh.detachMedia(); rh.loadSource(url); rh.attachMedia(video);
					return Promise.resolve();
				} catch (e) { try { video._cbxHls.destroy(); } catch (e2) {} video._cbxHls = null; }
			}
			return loadHlsJs().then(function (Hls) {
				if (!Hls || !Hls.isSupported()) throw new Error('hls.js unsupported');
				var cfg = { capLevelToPlayerSize: true, maxHeight: S.multiMaxHeight, preferManagedMediaSource: true };
				if (isDvrVideo(video)) cfg.pLoader = plainLoaderFor(Hls);
				var onFatal = opts && opts.onFatal, reusable = !!(opts && opts.reuse);
				if (opts) for (var k in opts) if (k !== 'onFatal' && k !== 'reuse') cfg[k] = opts[k];
				var h = new Hls(cfg);
				var netRetries = 0, mediaRetries = 0;
				h.on(Hls.Events.ERROR, function (_, d) {
					if (!d) return;
					var where = (d.frag && d.frag.relurl) || (d.url) || (d.context && d.context.url) || '';
					var code = d.response && d.response.code;
					var host = ''; try { host = new URL(where, location.href).host.split('.')[0]; } catch (e) {}
					log((d.fatal ? 'hls FATAL' : 'hls'), d.details, code ? 'http ' + code : '', host, where.slice(-60));
					if (/LoadTimeOut$/.test(d.details || '') && isDvrVideo(video)) video._cbxTimeouts = (video._cbxTimeouts || 0) + 1;
					if (!d.fatal) return;
					// 401, 403, 410: the address itself is refused (a signed address that ran out, a session that
					// ended). Asking the same address again, five times with growing waits, was twelve seconds of
					// black before the fresh start that was always going to be the answer. (Not 404: a live segment
					// that is not there yet, or not any more, is ordinary and the next try finds the next one.)
					var refused = code === 401 || code === 403 || code === 410;
					if (refused && d.type === Hls.ErrorTypes.NETWORK_ERROR && onFatal) {
						log('stream address refused (HTTP ' + code + '): a fresh address instead of retries');
						onFatal(d);
					} else if (d.type === Hls.ErrorTypes.NETWORK_ERROR && netRetries++ < 5) {
						setTimeout(function () { try { h.startLoad(); } catch (e) {} }, 800 * netRetries);
					} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR && mediaRetries++ < 3) {
						oursDo(function () { try { h.recoverMediaError(); } catch (e) {} });
						if (video._cbxAdopted) setTimeout(function () { if (video._cbxAdopted && video.paused && !P.userPaused && !P.sleeping) adoptPlay(video); }, 600);
					} else if (onFatal) {
						onFatal(d);
					} else {
						try { h.destroy(); } catch (e) {}
					}
				});
				h.on(Hls.Events.FRAG_BUFFERED, function () { netRetries = 0; mediaRetries = 0; });
				h.loadSource(url);
				// on an adopted site video the element is guarded against the site's own source changes;
				// the engine's are let through
				oursDo(function () { h.attachMedia(video); });
				h._reusable = reusable;
				video._cbxHls = h;
				log('attached via hls.js');
			}).catch(function (e) {
				// the site's own video already plays natively: there is nothing to fall back to, the adoption just fails
				if (video._cbxAdopted) throw e;
				log('hls.js unavailable (' + (e && e.message) + ') — native playback has no rewind');
				video.src = url;
				if (video.classList.contains('cbx-dvr')) {
					toast('Rewind needs hls.js, which the page blocked — see the Rewind tab');
				}
			});
		}
		if (video.canPlayType('application/vnd.apple.mpegurl')) {
			video.src = url;
			log('attached natively (no back buffer control)');
			if (video.classList.contains('cbx-dvr')) {
				P.native = true; updatePlayerToggle();
				toast(/iP(hone|ad|od)/.test(navigator.userAgent)
					? 'Deep rewind needs iOS 17.1 or newer; using the native player'
					: 'This browser has no Media Source support; using the native player');
			}
			return Promise.resolve();
		}
		return Promise.reject(new Error('no HLS support'));
	}


	/* ================================================================== *
	 * multi cam page
	 * ================================================================== */

	var MI = {
		plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
		reload: '<svg viewBox="0 0 24 24"><path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		mute: '<svg viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9H4zM16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		random: '<svg viewBox="0 0 24 24"><path d="M3 7h4l10 10h4M17 5l4 2-4 2M3 17h4l3-3M14 10l3-3h4M17 15l4 2-4 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		broom: '<svg viewBox="0 0 24 24"><path d="M15 3l6 6-8 8-6-6zM7 11l-4 4 3 3 4-4M13 13l4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		fs: '<svg viewBox="0 0 24 24"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		link: '<svg viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.5 1.5M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		pause: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M7 5h4v14H7zm6 0h4v14h-4z"/></svg>',
		play: '<svg viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z"/></svg>',
		cols: '<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4zM10 5v14M15 5v14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
		chev: '<svg viewBox="0 0 24 24" class="cbx-chev"><path d="M7 10l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		heart: '<svg viewBox="0 0 24 24"><path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
		sets: '<svg viewBox="0 0 24 24"><path d="M6 3h12v18l-6-4-6 4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
		open: '<svg viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9M18 14v5H5V6h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		big: '<svg viewBox="0 0 24 24"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		home: '<svg viewBox="0 0 24 24"><path d="M4 11l8-7 8 7M6 10v10h4v-6h4v6h4V10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
		close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>'
	};
	function buildMulti() {
		if (multiBuilt) return;
		multiBuilt = true;
		var uiStyle = el('style', { id: 'cbx-multi-ui-css' }, UI_CSS), pageStyle = el('style', { id: 'cbx-multi-css' }, MULTI_CSS);
		document.head.appendChild(uiStyle);
		document.head.appendChild(pageStyle);
		if (wantsTouchLayout()) document.documentElement.classList.add('cbx-touch');
		var root = el('div', { id: 'cbx-multi' },
			'<div id="cbx-multi-bar" role="toolbar">' +
			'<input type="text" id="cbx-multi-input" placeholder="Add a room by name" autocapitalize="off" autocorrect="off" spellcheck="false">' +
			'<button id="cbx-multi-add" class="cbx-b cbx-b-accent">' + MI.plus + 'Add</button>' +
			'<button id="cbx-multi-reload" class="cbx-b cbx-ib" title="Reload all" aria-label="Reload all">' + MI.reload + '</button>' +
			'<button id="cbx-multi-mute" class="cbx-b cbx-it" title="Mute every tile (also: click an empty spot)">' + MI.mute + '<span>Mute all</span></button>' +
			'<button id="cbx-multi-random" class="cbx-b cbx-it" title="Add random rooms">' + MI.random + '<span>Random</span></button>' +
			'<button id="cbx-multi-rm-off" class="cbx-b cbx-it" title="Remove the rooms that are offline">' + MI.broom + '<span>Remove offline</span></button>' +
			'<button id="cbx-multi-rm-all" class="cbx-b cbx-it" title="Remove every room">' + MI.trash + '<span>Remove all</span></button>' +
			'<button id="cbx-multi-follow" class="cbx-b cbx-it" title="Add the rooms you follow that are online">' + MI.heart + '<span>Followed</span></button>' +
			'<span class="cbx-setswrap"><button id="cbx-multi-sets" class="cbx-b cbx-it" title="Save this set of rooms, or load one">' + MI.sets + '<span>Sets</span></button></span>' +
			'<span class="cbx-grow"></span>' +
			'<span class="cbx-ddwrap"><button id="cbx-multi-cols" class="cbx-b cbx-it" title="Layout: columns, and whether the tiles fill the window" aria-haspopup="true">' + MI.cols + '<span id="cbx-multi-cols-lbl">Auto columns</span><b id="cbx-multi-cols-short">Auto</b>' + MI.chev + '</button></span>' +
			'<button id="cbx-multi-fs" class="cbx-b cbx-ib" title="Fullscreen (F11 · F10 hides the toolbar)" aria-label="Fullscreen">' + MI.fs + '</button>' +
			'<button id="cbx-multi-share" class="cbx-b cbx-ib" title="Copy a link that opens this set of rooms" aria-label="Copy share link">' + MI.link + '</button>' +
			'<button id="cbx-multi-exit" class="cbx-b cbx-ib" title="Leave multi cam and go to the home page" aria-label="Leave multi cam">' + MI.home + '</button>' +
			'</div><div id="cbx-multi-hot"></div><div id="cbx-multi-grid"></div>' +
			'<p id="cbx-multi-empty">No rooms yet. Type a name above, or use the + button on any room card.</p>');
		document.body.appendChild(root);
		document.documentElement.classList.add('cbx-multi-page');
		document.documentElement.style.overflow = 'hidden';
		document.title = 'Multi cam';
		// the phone site may re-render <body> after we mounted, or swap the body element itself: put the page back
		setInterval(function () {
			if (!root.isConnected && document.body) { log('multi: page was removed by the site; mounting it again'); document.body.appendChild(root); }
			// the class that hides the site's page and our two stylesheets can be wiped just like the root
			var de = document.documentElement;
			if (!de.classList.contains('cbx-multi-page')) { log('multi: page class was removed; putting it back'); de.classList.add('cbx-multi-page'); de.style.overflow = 'hidden'; }
			if (document.head) { if (!uiStyle.isConnected) document.head.appendChild(uiStyle); if (!pageStyle.isConnected) document.head.appendChild(pageStyle); }
			if (document.title !== 'Multi cam') document.title = 'Multi cam';
		}, 1000);
		broadcast({ type: 'multi-up', from: TAB_ID });
		log('multi: viewer mounted' + (multiInPlace ? ' in place' : ''));
		// keep the flag in the address, so a reload, a restored tab or a bookmark lands here again
		try {
			if (!/[?&]cbx-multi=1/.test(location.search) && isHomePath()) history.replaceState(history.state, '', '/?cbx-multi=1' + (/cbx-multi/.test(location.hash) ? location.hash : '#cbx-multi'));
		} catch (e) {}
		try { localStorage.removeItem(MULTI_HANDOFF); } catch (e) {}

		var grid = $('#cbx-multi-grid', root), empty = $('#cbx-multi-empty', root);
		var dragCell = null, hoverPrev = null;
		function sync() { empty.style.display = grid.children.length ? 'none' : 'block'; if (S.multiFill && typeof applyFill === 'function') applyFill(); }

		function addCam(user) {
			user = String(user || '').trim().toLowerCase().replace(/[^a-z0-9_]/g, '');
			if (!user || $('.cbx-cam[data-user="' + user + '"]', grid)) return;

			var cell = el('div', { 'class': 'cbx-cam', 'data-user': user },
				'<video muted playsinline webkit-playsinline autoplay></video>' +
				'<span class="cbx-name">' + user + '</span>' +
				'<span class="cbx-state">Loading</span>' +
				'<span class="cbx-tools">' +
				'<button class="cbx-tb cbx-tb-open" title="Open the room in a new tab">' + MI.open + '</button>' +
				'<button class="cbx-tb cbx-tb-play" title="Pause">' + MI.pause + '</button>' +
				'<button class="cbx-tb cbx-tb-big" title="Enlarge (or double-click the picture)">' + MI.big + '</button>' +
				'<button class="cbx-tb cbx-x" aria-label="Remove ' + user + '" title="Remove">' + MI.close + '</button></span>' +
				(S.multiShowSubject ? '<span class="cbx-subject"></span>' : ''));
			cell.draggable = true;
			grid.appendChild(cell); sync();

			var video = $('video', cell), state = $('.cbx-state', cell);

			cell.addEventListener('dragstart', function (e) {
				dragCell = cell; cell.classList.add('cbx-dragging');
				try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', user); } catch (err) {}
			});
			cell.addEventListener('dragend', function () { dragCell = null; cell.classList.remove('cbx-dragging'); clearDrop(); });
			cell.addEventListener('dragover', function (e) {
				if (!dragCell || dragCell === cell) return;
				e.preventDefault();
				var r = cell.getBoundingClientRect(), before = e.clientX < r.left + r.width / 2;
				clearDrop(); cell.classList.add(before ? 'cbx-drop-before' : 'cbx-drop-after');
			});
			cell.addEventListener('drop', function (e) {
				if (!dragCell || dragCell === cell) return;
				e.preventDefault();
				var r = cell.getBoundingClientRect(), before = e.clientX < r.left + r.width / 2;
				grid.insertBefore(dragCell, before ? cell : cell.nextSibling);
				clearDrop(); saveOrder();
			});

			cell.addEventListener('mouseenter', function () {
				if (!S.multiHoverAudio || video.paused) return;
				hoverPrev = $$('.cbx-cam', grid).filter(function (c) { return !$('video', c).muted; });
				$$('.cbx-cam video', grid).forEach(function (v) { v.muted = v !== video; });
			});
			cell.addEventListener('mouseleave', function () {
				if (!S.multiHoverAudio || !hoverPrev) return;
				$$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; });
				hoverPrev.forEach(function (c) { var v = $('video', c); if (v && c.isConnected) v.muted = false; });
				hoverPrev = null;
			});

			$('.cbx-x', cell).addEventListener('click', function () {
				if (bigEl && bigEl._cell === cell) closeBig();
				try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {}
				cell.remove();
				jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; }));
				sync();
			});

			$('.cbx-tb-play', cell).addEventListener('click', function (e) {
				e.stopPropagation();
				if (video.paused) { cell._userPaused = false; var pp = video.play(); if (pp && pp.catch) pp.catch(function () {}); } else { cell._userPaused = true; video.pause(); }
			});
			// a pause the viewer asked for (also from the enlarged view's own controls) must survive the
			// watchdog; a pause the browser applied because the tab went to the background must not
			video.addEventListener('pause', function () {
				if (cell._reloading || cell._sleeping || document.hidden) return;
				setTimeout(function () { if (video.paused && !document.hidden && !cell._reloading && !cell._sleeping && Date.now() - shownAt > 1500) cell._userPaused = true; }, 400);
			});
			video.addEventListener('play', function () { cell._userPaused = false; });
			var syncPlay = function () { var b = $('.cbx-tb-play', cell); if (b) { b.innerHTML = video.paused ? MI.play : MI.pause; b.title = video.paused ? 'Play' : 'Pause'; } cell.classList.toggle('cbx-paused', video.paused); };
			video.addEventListener('play', syncPlay); video.addEventListener('pause', syncPlay);
			$('.cbx-tb-big', cell).addEventListener('click', function (e) { e.stopPropagation(); enlarge(cell); });
			$('.cbx-tb-open', cell).addEventListener('click', function (e) { e.stopPropagation(); window.open(location.origin + '/' + user + '/', '_blank'); });
			video.addEventListener('dblclick', function (e) { e.preventDefault(); enlarge(cell); });
			// touch: a tap shows the buttons for a moment
			cell.addEventListener('pointerdown', function (e) { if (e.pointerType === 'mouse') return; cell.classList.add('cbx-show'); clearTimeout(cell._t); cell._t = setTimeout(function () { cell.classList.remove('cbx-show'); }, 3000); });
			video.addEventListener('click', function () {
				var wasMuted = video.muted;
				$$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; });
				$$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); });
				video.muted = !wasMuted;
				cell.classList.toggle('cbx-live-audio', !video.muted);
			});

			// status: the subject line, and a badge when the room is in a private, away or password show
			var readStatus = function () {
				return fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include' })
					.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
					.then(function (d) {
						if (!d) return '';
						var sub = $('.cbx-subject', cell);
						if (sub && d.room_title) sub.textContent = d.room_title;
						var st = String(d.room_status || '').toLowerCase();
						cell.setAttribute('data-status', st);
						if (st && st !== 'public' && st !== 'offline' && cell.getAttribute('data-offline')) state.textContent = st.charAt(0).toUpperCase() + st.slice(1) + ' show';
						if (S.multiHidePrivate && st && st !== 'public') cell.style.display = 'none';
						return st;
					}).catch(function () { return ''; });
			};
			readStatus();
			cell._readStatus = readStatus;

			cell._video = video;
			startTile(cell);

			var list = jsonGet(MULTI_KEY, []);
			if (list.indexOf(user) === -1) { list.push(user); jsonSet(MULTI_KEY, list); }
		}

		/* ---- tile lifecycle ----
		 * A tile used to be loaded once and left alone. On a phone that does not last: a tab in the
		 * background is frozen, its requests are cut, the playlist window slides past the playhead and
		 * the signed address can expire; hls.js then ran out of retries, the instance was destroyed and
		 * the tile stayed black until the page was reloaded. Tiles now (re)start from a fresh address,
		 * a watchdog restarts the ones that stop moving, and on phones the muted tiles are put to
		 * sleep while the tab is hidden and woken when it is shown again. */
		var phoneLike = wantsTouchLayout(), hidAt = 0, shownAt = Date.now();
		function tiles() { return $$('.cbx-cam', grid); }
		function playTile(v) { try { var p = v.play(); if (p && p.catch) p.catch(function () {}); } catch (e) {} }
		function toLiveEdge(v) {
			try {
				var h = v._cbxHls;
				if (h) { if (h.liveSyncPosition != null && h.liveSyncPosition > v.currentTime + 8) v.currentTime = h.liveSyncPosition; }
				else if (v.seekable && v.seekable.length) { var end = v.seekable.end(v.seekable.length - 1); if (isFinite(end) && end > v.currentTime + 8) v.currentTime = end - 2; }
			} catch (e) {}
		}
		function startTile(cell) {
			var user = cell.getAttribute('data-user'), video = cell._video, state = $('.cbx-state', cell);
			if (!video) return;
			var seq = cell._seq = (cell._seq || 0) + 1;
			cell._reloading = true; cell._dead = false; cell._soft = false; cell._stuck = 0; cell._lastT = -1; cell._loadedAt = Date.now();
			try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {}
			video._cbxHls = null;
			try { if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); } } catch (e) {}
			if (state) { state.style.display = ''; state.textContent = cell._loads ? 'Reconnecting' : 'Loading'; }
			cell._loads = (cell._loads || 0) + 1;
			hlsFor(user).then(function (url) {
				if (seq !== cell._seq || !cell.isConnected) return;
				if (!url) {
					var st0 = cell.getAttribute('data-status');
					if (state) state.textContent = st0 && st0 !== 'public' && st0 !== 'offline' ? st0.charAt(0).toUpperCase() + st0.slice(1) + ' show' : 'Offline';
					cell.setAttribute('data-offline', '1');
					if (cell._readStatus) cell._readStatus();
					if (S.multiAutoRemove) {
						removeCell(cell);
						jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; }));
						sync();
					} else if (S.multiHideOffline) cell.style.display = 'none';
					return;
				}
				cell.removeAttribute('data-offline');
				return attachStream(video, url, {
					backBufferLength: phoneLike ? 15 : 120,
					onFatal: function (d) { if (seq === cell._seq) { cell._dead = true; log('multi', user, 'player gave up:', d && d.details); } }
				}).then(function () {
					if (seq !== cell._seq) return;
					if (state) state.style.display = 'none';
					if (!cell._userPaused && !(phoneLike && document.hidden)) playTile(video);
				});
			}).catch(function (e) {
				if (seq !== cell._seq) return;
				cell._dead = true;
				if (state) { state.style.display = ''; state.textContent = navigator.onLine === false ? 'No connection' : 'Could not load'; }
				log('multi', user, e && (e.message || e));
			}).then(function () { if (seq === cell._seq) cell._reloading = false; });
		}
		// every few seconds while the tab is visible: a tile that is not moving gets a nudge, then a fresh start
		function checkTiles() {
			if (document.hidden) return;
			var now = Date.now();
			tiles().forEach(function (c) {
				var v = c._video;
				if (!v || c._reloading || c._sleeping || c._userPaused || c.getAttribute('data-offline') || c.style.display === 'none') return;
				if (now - (c._loadedAt || 0) < 10000 && !c._dead) return;     // still starting up
				var t = v.currentTime, moved = Math.abs(t - (c._lastT == null ? -1 : c._lastT)) > 0.1;
				c._lastT = t;
				if (moved) {
					c._stuck = 0; c._soft = false;
					if (now - (c._loadedAt || 0) > 30000) c._revives = 0;
					return;
				}
				c._stuck = (c._stuck || 0) + 1;
				if (v.paused) playTile(v);
				if (!c._dead && c._stuck < 2) return;
				if (!c._dead && !c._soft && v._cbxHls) {
					// first the cheap fix: restart the loaders and jump to the live edge
					c._soft = true; c._stuck = 0;
					log('multi', c.getAttribute('data-user'), 'not moving; restarting the loader');
					try { v._cbxHls.startLoad(-1); } catch (e) {}
					toLiveEdge(v); playTile(v);
					return;
				}
				if (now < (c._nextRevive || 0)) return;
				c._revives = (c._revives || 0) + 1;
				c._nextRevive = now + Math.min(60000, 3000 * Math.pow(2, c._revives - 1));
				log('multi', c.getAttribute('data-user'), 'restarting the tile (#' + c._revives + ')');
				startTile(c);
			});
		}
		setInterval(checkTiles, 4000);
		function sleepTiles() {
			hidAt = Date.now();
			if (!phoneLike) return;
			tiles().forEach(function (c) {
				var v = c._video;
				if (!v || c.getAttribute('data-offline')) return;
				if (!v.muted && !v.paused) return;       // the tile with sound may keep playing in the background
				c._sleeping = true;
				try { if (v._cbxHls) v._cbxHls.stopLoad(); } catch (e) {}
				try { v.pause(); } catch (e) {}
			});
		}
		function wakeTiles(why) {
			var away = hidAt ? Date.now() - hidAt : 0, n = 0;
			hidAt = 0; shownAt = Date.now();
			log('multi: tab shown (' + why + ') after ' + Math.round(away / 1000) + 's');
			tiles().forEach(function (c) {
				var v = c._video;
				if (!v || c.getAttribute('data-offline')) return;
				var slept = c._sleeping; c._sleeping = false;
				if (c._userPaused || c._reloading) return;
				var gone = c._dead || (canUseMse() ? !v._cbxHls : !(v.currentSrc || v.src));
				// a short nap resumes in place. Anything longer has outlived the playlist window (and perhaps
				// the signed address): a fresh start is quicker and surer than waiting for the old session to fail
				if (gone || why === 'pageshow' || (phoneLike && away > 20000)) { setTimeout(function () { if (c.isConnected && !c._userPaused) startTile(c); }, 150 * n++); return; }
				if (slept) { try { if (v._cbxHls) v._cbxHls.startLoad(-1); } catch (e) {} }
				c._stuck = 0; c._lastT = -1; c._loadedAt = Date.now() - 6000;
				toLiveEdge(v); playTile(v);
			});
		}
		document.addEventListener('visibilitychange', function () { if (document.hidden) sleepTiles(); else wakeTiles('visible'); });
		document.addEventListener('resume', function () { if (!document.hidden) wakeTiles('resume'); });
		window.addEventListener('pageshow', function (e) { if (e.persisted) wakeTiles('pageshow'); });
		window.addEventListener('online', function () { tiles().forEach(function (c) { c._nextRevive = 0; if (c._dead) c._stuck = 9; }); checkTiles(); });

		$('#cbx-multi-exit', root).addEventListener('click', function () { leaveMulti(); });

		$('#cbx-multi-add', root).addEventListener('click', function () {
			var i = $('#cbx-multi-input', root);
			i.value.split(',').forEach(addCam);
			i.value = '';
		});
		$('#cbx-multi-input', root).addEventListener('keydown', function (e) {
			if (e.key === 'Enter') $('#cbx-multi-add', root).click();
		});
		$('#cbx-multi-reload', root).addEventListener('click', function () {
			var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); });
			$$('.cbx-cam', grid).forEach(removeCell);
			grid.innerHTML = ''; users.forEach(addCam);
		});
		function muteAll() {
			$$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; });
			$$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); });
		}
		$('#cbx-multi-mute', root).addEventListener('click', muteAll);
		root.addEventListener('click', function (e) { if (e.target === root || e.target === grid || e.target === empty) muteAll(); });

		$('#cbx-multi-random', root).addEventListener('click', function () { fillRandom(addCam); });
		// followed rooms that are online now
		$('#cbx-multi-follow', root).addEventListener('click', function () {
			toast('Reading your followed rooms…');
			var tryUrl = function (u) { return fetch(u, { credentials: 'include' }).then(function (r) { if (!r.ok) throw new Error('bad status'); return r.json(); }).then(function (d) { return harvestUsernames(d, []); }); };
			// signed out, the site answers the follow filter with the plain public list; compare the two and refuse that
			Promise.all([tryUrl('/api/ts/roomlist/room-list/?follow=true&limit=90'), tryUrl('/api/ts/roomlist/room-list/?limit=90')])
				.then(function (r) {
					var names = r[0], plain = r[1];
					var same = names.length && names.length === plain.length && names.every(function (u, i) { return u === plain[i]; });
					if (same) { toast('That is the public room list, not your followed rooms — sign in first'); return; }
					var have = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); });
					var add = names.filter(function (u) { return have.indexOf(u) === -1; });
					if (!add.length) { toast(names.length ? 'All your online followed rooms are here already' : 'No followed rooms online'); return; }
					if (add.length > 12 && !confirm('Add ' + add.length + ' followed rooms that are online now?')) return;
					add.forEach(addCam); toast('Added ' + add.length + ' followed room' + (add.length === 1 ? '' : 's'));
				}).catch(function () { toast('Could not read your followed rooms — are you signed in?'); });
		});
		// named sets: save the current rooms under a name, load one back, delete one
		var setsBtn = $('#cbx-multi-sets', root), setsMenu = null;
		function closeSets() { if (setsMenu) { setsMenu.remove(); setsMenu = null; } }
		function loadSet(users) {
			$$('.cbx-cam', grid).forEach(removeCell);
			jsonSet(MULTI_KEY, []);
			users.forEach(addCam); sync();
		}
		setsBtn.addEventListener('click', function (e) {
			e.stopPropagation();
			if (setsMenu) { closeSets(); return; }
			var sets = jsonGet(MULTI_SETS_KEY, {}), names = Object.keys(sets).sort();
			setsMenu = el('div', { id: 'cbx-multi-setsmenu' },
				(names.length ? names.map(function (n) { return '<div class="cbx-setrow"><button class="cbx-setload" data-set="' + esc(n) + '">' + esc(n) + '<small>' + sets[n].length + ' rooms</small></button><button class="cbx-setdel" data-set="' + esc(n) + '" title="Delete this set">' + MI.close + '</button></div>'; }).join('') : '<p class="cbx-note">No saved sets yet.</p>') +
				'<button class="cbx-b cbx-b-wide cbx-b-accent" id="cbx-setsave">Save current rooms as…</button>');
			placeMenu(setsMenu, setsBtn);
			setsMenu.addEventListener('click', function (ev) {
				ev.stopPropagation();
				var load = ev.target.closest('.cbx-setload'), del = ev.target.closest('.cbx-setdel');
				if (load) { var u = (jsonGet(MULTI_SETS_KEY, {})[load.getAttribute('data-set')] || []); closeSets(); loadSet(u); toast('Loaded ' + u.length + ' rooms'); }
				else if (del) { var all = jsonGet(MULTI_SETS_KEY, {}); delete all[del.getAttribute('data-set')]; jsonSet(MULTI_SETS_KEY, all); closeSets(); setsBtn.click(); }
				else if (ev.target.id === 'cbx-setsave') {
					var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); });
					if (!users.length) { toast('Add some rooms first'); return; }
					var name = prompt('Name for this set of ' + users.length + ' rooms', ''); if (!name) return;
					var all2 = jsonGet(MULTI_SETS_KEY, {}); all2[name.trim().slice(0, 40)] = users; jsonSet(MULTI_SETS_KEY, all2);
					closeSets(); toast('Saved "' + name.trim() + '"');
				}
			});
		});
		document.addEventListener('click', closeSets);
		// tiles that have no picture (offline, private, away) ask once a minute; when the room is public again they reload in place
		setInterval(function () {
			if (document.hidden) return;
			$$('.cbx-cam[data-offline]', grid).forEach(function (c) {
				if (!c._readStatus) return;
				c._readStatus().then(function (st) {
					if (st !== 'public') return;
					var u = c.getAttribute('data-user'), next = c.nextSibling;
					removeCell(c); addCam(u);
					var fresh = $('.cbx-cam[data-user="' + u + '"]', grid);
					if (fresh && next && next.parentNode === grid) grid.insertBefore(fresh, next);
					toast(u + ' is back');
				});
			});
		}, 60000);

		// one tile at full size, with the browser's own controls and sound; the tile gets its video back on close
		var bigEl = null;
		function enlarge(cell) {
			var video = $('video', cell); if (!video) return;
			if (bigEl) closeBig();
			bigEl = el('div', { id: 'cbx-multi-big' }, '<span class="cbx-big-top"><span class="cbx-big-name">' + esc(cell.getAttribute('data-user')) + '</span><button class="cbx-big-pop" title="Open in its own window">' + MI.open + '<span>Pop out</span></button><button class="cbx-big-x" aria-label="Close">' + MI.close + '</button></span>');
			bigEl._cell = cell; bigEl._video = video; bigEl._muted = video.muted;
			video.controls = true; video.muted = false;
			bigEl.insertBefore(video, bigEl.firstChild);
			root.appendChild(bigEl);
			vpUse(true);
			backPush('big', function () { closeBig('back'); });
			$('.cbx-big-x', bigEl).addEventListener('click', function () { closeBig(); });
			$('.cbx-big-pop', bigEl).addEventListener('click', function () { var u = cell.getAttribute('data-user'); closeBig(); openTheatre(u); });
			bigEl.addEventListener('click', function (e) { if (e.target === bigEl) closeBig(); });
			video.addEventListener('dblclick', function once(e) { video.removeEventListener('dblclick', once); e.preventDefault(); closeBig(); });
			var p = video.play(); if (p && p.catch) p.catch(function () {});
		}
		function closeBig(how) {
			if (!bigEl) return;
			vpUse(false);
			if (how !== 'back') backPop('big');
			var video = bigEl._video, cell = bigEl._cell;
			video.controls = false; video.muted = bigEl._muted;
			if (cell && cell.isConnected) cell.insertBefore(video, cell.firstChild);
			else { try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {} }
			bigEl.remove(); bigEl = null;
		}
		function removeCell(c) {
			if (bigEl && bigEl._cell === c) closeBig();
			var v = $('video', c);
			try { if (v && v._cbxHls) v._cbxHls.destroy(); } catch (e) {}
			c.remove();
		}
		function saveOrder() {
			jsonSet(MULTI_KEY, $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); }));
			sync();
		}
		function clearDrop() { $$('.cbx-drop-before,.cbx-drop-after', grid).forEach(function (c) { c.classList.remove('cbx-drop-before', 'cbx-drop-after'); }); }

		$('#cbx-multi-rm-off', root).addEventListener('click', function () {
			$$('.cbx-cam[data-offline]', grid).forEach(removeCell); saveOrder();
		});
		$('#cbx-multi-rm-all', root).addEventListener('click', function () {
			if (!grid.children.length || !confirm('Remove every room from the multi cam list?')) return;
			$$('.cbx-cam', grid).forEach(removeCell); saveOrder();
		});
		var colsEl = $('#cbx-multi-cols', root), colsMenu = null;
		function applyCols() {
			if (S.multiCols && !S.multiFill) grid.setAttribute('data-cols', S.multiCols); else grid.removeAttribute('data-cols');
			$('#cbx-multi-cols-lbl', root).textContent = (S.multiCols ? S.multiCols + ' column' + (S.multiCols === 1 ? '' : 's') : 'Auto columns') + (S.multiFill ? ' · Fill' : '');
			$('#cbx-multi-cols-short', root).textContent = (S.multiCols ? String(S.multiCols) : 'Auto') + (S.multiFill ? '·F' : '');
		}
		// the bar scrolls sideways, and a scroll container clips anything hanging below it: menus are
		// placed with fixed coordinates from their button and appended to the page instead
		function placeMenu(menu, btn) {
			var r = btn.getBoundingClientRect(); root.appendChild(menu);
			var w = menu.offsetWidth || 200, left = Math.min(Math.max(8, r.left), innerWidth - w - 8);
			menu.style.left = Math.round(left) + 'px'; menu.style.top = Math.round(r.bottom + 6) + 'px';
		}
		function closeCols() { if (colsMenu) { colsMenu.remove(); colsMenu = null; } }
		colsEl.addEventListener('click', function (e) {
			e.stopPropagation();
			if (colsMenu) { closeCols(); return; }
			colsMenu = el('div', { 'class': 'cbx-mini' }, [[0, 'Auto'], [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5'], [6, '6'], [7, '7'], [8, '8']].map(function (c) {
				return '<button data-cols="' + c[0] + '"' + ((S.multiCols || 0) === c[0] ? ' class="cbx-on"' : '') + '>' + c[1] + (c[0] ? ' columns' : ' columns (by width)') + '</button>';
			}).join('') + '<hr><button data-fill="1"' + (S.multiFill ? ' class="cbx-on"' : '') + '>Fill the screen<small>rows sized to the window, no scrolling</small></button>');
			placeMenu(colsMenu, colsEl);
			colsMenu.addEventListener('click', function (ev) {
				ev.stopPropagation();
				var b = ev.target.closest('[data-cols]'), f = ev.target.closest('[data-fill]');
				if (b) { S.multiCols = parseInt(b.getAttribute('data-cols'), 10) || 0; save(); applyCols(); applyFill(); closeCols(); }
				else if (f) { S.multiFill = !S.multiFill; save(); applyCols(); applyFill(); f.classList.toggle('cbx-on', S.multiFill); }
			});
		});
		document.addEventListener('click', closeCols);
		// fill screen: pick the column count that gives the biggest 16:9 tiles for this many rooms and this window
		function applyFill() {
			root.classList.toggle('cbx-fill', !!S.multiFill);
			if (!S.multiFill) { grid.style.gridTemplateColumns = ''; grid.style.gridAutoRows = ''; grid.style.height = ''; return; }
			var cells = $$('.cbx-cam', grid).filter(function (c) { return c.style.display !== 'none'; }), n = cells.length || 1;
			var bar = $('#cbx-multi-bar', root), bare = root.classList.contains('cbx-bare');
			var W = root.clientWidth - (bare ? 0 : 20), H = innerHeight - (bare ? 0 : bar.offsetHeight + 30), gap = bare ? 2 : 8, best = 1, bestSize = 0;
			for (var cols = 1; cols <= n; cols++) {
				var rows = Math.ceil(n / cols), tw = (W - gap * (cols - 1)) / cols, th = (H - gap * (rows - 1)) / rows;
				var size = Math.min(tw, th * 16 / 9);
				if (size > bestSize) { bestSize = size; best = cols; }
			}
			if (S.multiCols) best = Math.min(S.multiCols, n);
			var rowsN = Math.ceil(n / best);
			grid.style.gridTemplateColumns = 'repeat(' + best + ',1fr)';
			grid.style.gridAutoRows = Math.floor((H - gap * (rowsN - 1)) / rowsN) + 'px';
			grid.style.height = H + 'px';
		}
		window.addEventListener('resize', function () { if (S.multiFill) applyFill(); });
		applyCols(); applyFill();

		function toggleBare(on) {
			if (on == null) on = !root.classList.contains('cbx-bare');
			root.classList.toggle('cbx-bare', on);
		}
		function toggleFs() {
			try {
				if (document.fullscreenElement || document.webkitFullscreenElement) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
				else (root.requestFullscreen || root.webkitRequestFullscreen).call(root);
			} catch (e) {}
		}
		$('#cbx-multi-fs', root).addEventListener('click', toggleFs);
		// an iPhone cannot put a page element fullscreen; a button that does nothing is worse than none
		if (!((document.fullscreenEnabled || document.webkitFullscreenEnabled) && (root.requestFullscreen || root.webkitRequestFullscreen))) $('#cbx-multi-fs', root).style.display = 'none';
		// fullscreen keeps the toolbar where it is (F10 hides it); the fill layout re-measures
		document.addEventListener('fullscreenchange', function () { root.classList.remove('cbx-bare'); if (S.multiFill) applyFill(); });
		document.addEventListener('keydown', function (e) {
			if (e.target.matches && e.target.matches('input,textarea,select')) return;
			if (e.key === 'F11') { e.preventDefault(); toggleFs(); }
			else if (e.key === 'F10') { e.preventDefault(); toggleBare(); }
			else if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
				var shown = $$('.cbx-cam', grid).filter(function (c) { return c.style.display !== 'none'; }), pick = shown[parseInt(e.key, 10) - 1];
				if (!pick) return;
				muteAll(); var pv = $('video', pick); if (pv) { pv.muted = false; pick.classList.add('cbx-live-audio'); }
			}
			else if (e.key === '0') muteAll();
			else if (e.key === 'Escape' && bigEl) closeBig();
			else if (e.key === 'Escape' && root.classList.contains('cbx-bare') && !document.fullscreenElement) toggleBare(false);
		});
		$('#cbx-multi-share', root).addEventListener('click', function () {
			var users = $$('.cbx-cam', grid).map(function (c) { return c.getAttribute('data-user'); });
			if (!users.length) { toast('Nothing to share yet'); return; }
			var url = location.origin + '/?cbx-multi=1&cbx-rooms=' + users.join(',') + '#cbx-multi&cbx-rooms=' + users.join(',');
			var done = function () { toast('Link copied — it works for anyone with Enhanced Plus'); };
			if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(done, function () { prompt('Share link', url); });
			else prompt('Share link', url);
		});

		jsonGet(MULTI_KEY, []).forEach(addCam);
		var m = location.search.match(/[?&]cbx-rooms=([^&]+)/) || location.hash.match(/[#&]cbx-rooms=([^&]+)/) || ADDR.q.match(/[?&]cbx-rooms=([^&]+)/) || ADDR.h.match(/[#&]cbx-rooms=([^&]+)/);
		if (m) { try { decodeURIComponent(m[1]).split(',').forEach(addCam); } catch (e) {} }
		sync();
	}

	/* ================================================================== *
	 * inactive tabs, exclusive audio
	 * ================================================================== */

	var chan = null;
	try { chan = new BroadcastChannel('cbx'); } catch (e) {}

	function broadcast(msg) { try { if (chan) chan.postMessage(msg); } catch (e) {} }

	function muteEverythingHere() {
		$$('video').forEach(function (v) { v.muted = true; });
	}

	if (chan) {
		chan.onmessage = function (e) {
			if (!e.data) return;
			if (e.data.type === 'multi-up' && e.data.from !== TAB_ID) { multiChildUp = Date.now(); return; }
			if (e.data.type === 'mute-others' && e.data.from !== TAB_ID) muteEverythingHere();
			if (e.data.type === 'alert' && e.data.from !== TAB_ID) toast(e.data.text);
		};
	}
	var TAB_ID = String(Math.random()).slice(2);

	function capQuality(low) {
		$$('video').forEach(function (v) {
			var h = v._cbxHls;
			if (!h || !h.levels || !h.levels.length) return;
			// loadLevel only changes what gets fetched next; currentLevel would
			// flush the rewind buffer every time the tab goes to the background
			if (low) {
				if (v._cbxPrevLevel == null) v._cbxPrevLevel = h.loadLevel;
				h.loadLevel = 0;
			} else if (v._cbxPrevLevel != null) {
				h.loadLevel = v._cbxPrevLevel;
				v._cbxPrevLevel = null;
			}
		});
	}

	var pausedByUs = [];

	function applyInactive(hidden) {
		if (hidden) {
			if (S.inactiveQuality) capQuality(true);
			if (S.inactivePause) {
				pausedByUs = $$('video').filter(function (v) { return !v.paused; });
				pausedByUs.forEach(function (v) { try { v.pause(); } catch (e) {} });
			}
		} else {
			if (S.inactiveQuality) capQuality(false);
			pausedByUs.forEach(function (v) { try { v.play(); } catch (e) {} });
			pausedByUs = [];
		}
	}

	// repeated stalls: step the stream one rung down and keep it there for a while
	var stallLog = [], errorHold = 0;

	function stepQualityDown(v) {
		if (P.video && P.eng && v === P.video) {
			var cur = P.eng.active && P.eng.active();
			var next = bestTrackUnder((cur && cur.height ? cur.height : 9999) - 1);
			if (next && (!cur || next.height < cur.height)) { pinTrack(next); return next.label || (next.height + 'p'); }
			return null;
		}
		var h = v._cbxHls;
		if (h && h.levels && h.levels.length) {
			var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel;
			if (lvl > 0) { h.loadLevel = lvl - 1; return h.levels[lvl - 1].height + 'p'; }
		}
		return null;
	}

	function installErrorQuality() {
		['stalled', 'error'].forEach(function (ev) {
			document.addEventListener(ev, function (e) {
				if (!S.errorQuality || !roomName()) return;
				var v = e.target;
				if (!(v instanceof HTMLVideoElement) || v !== activeVideo()) return;
				var now = Date.now();
				stallLog = stallLog.filter(function (t) { return now - t < 60000; });
				stallLog.push(now);
				if (stallLog.length < 3 || now < errorHold) return;
				stallLog = [];
				var to = stepQualityDown(v);
				if (!to) return;
				errorHold = now + 5 * 60000;
				toast('Stream kept stalling — dropped to ' + to + ' for a few minutes');
			}, true);
		});
	}

	// a room opened in a background tab: the site waits for the tab; we do not
	// the site parks a "click to play" image over rooms that were opened in a background tab
	var sitePlayKickAt = 0;
	function kickSitePlay() {
		var btn = $('img[src*="play-inactive"]');
		if (!btn || Date.now() - sitePlayKickAt < 6000) return false;
		sitePlayKickAt = Date.now();
		clickHard(btn.closest('button,a,div') || btn); log('inactive: clicked the site play button');
		return true;
	}
	function installInactiveLoad() {
		if (!S.inactiveLoad || !document.hidden || !roomName()) return;
		var phone = isPhone(), tries = 0, shown = 0, t = setInterval(function () {
			if (!roomName()) { clearInterval(t); return; }
			var v = siteVideo();
			if (v && !v.paused && v.readyState >= 3) { clearInterval(t); return; }
			if (document.hidden) {
				// phones do not start playback in the background at all: wait for the tab instead of clicking at it
				if (phone || ++tries > 60) return;
			} else if (++shown > 8) { clearInterval(t); return; }
			kickSitePlay();
			if (!v || !document.hidden) return;
			if (!priorMuted.has(v)) priorMuted.set(v, v.muted);
			v.muted = true;
			var p = v.play(); if (p && p.catch) p.catch(function () {});
		}, 2000);
	}

	/* ---- coming back to a tab ----
	 * A tab in the background is throttled on a desktop and frozen outright on a phone: timers
	 * stop, requests are cut, the decoder is released, the live playlist slides past the playhead
	 * and the signed stream address can expire. Coming back used to leave a black or frozen
	 * picture that only a page reload fixed. Now the way back is walked in steps, cheapest first:
	 *   1. restart the loaders, catch up to live if that is where the viewer was, press play;
	 *   2. if the picture is still not moving, rebuild our player from a fresh address;
	 *   3. if even that fails while the room is live, reload the page once — what the viewer did by hand. */
	var tabHidAt = 0, tabShownAt = 0, tabWasLive = true, tabWasPlaying = true, resumeTimer = null, resumeSeq = 0;

	/* ---- phones: a background tab lets go of its player ----
	 * A hidden tab on a phone is frozen or throttled, its buffer can be emptied by the browser, the
	 * playlist slides past the playhead and the signed address runs out; every such tab also keeps a
	 * hardware decoder and tens of megabytes it cannot use. Nursing that session back to life was what
	 * failed. After SLEEP_AFTER in the background the player is released (where timers still run), and
	 * a tab that was away that long always comes back with a fresh engine on a fresh address. A short
	 * look at another tab keeps everything, rewind window included. */
	var sleepTimer = null, SLEEP_AFTER = 25000;
	function canSleep() { return !!S.phoneSleep && !!S.autoRecover && isPhone() && !!roomName() && !!P.video && !!P.eng && !recording(); }
	function scheduleSleep() {
		clearTimeout(sleepTimer);
		if (!canSleep()) return;
		sleepTimer = setTimeout(function () { if (document.hidden) sleepPlayer('background'); }, SLEEP_AFTER);
	}
	function showPoster(v) {
		try {
			if (!P.shell || !v || !v.videoWidth) return;
			var old = $('#cbx-poster'); if (old) { freeCanvas(old); old.remove(); }
			var c = document.createElement('canvas'), k = Math.min(1, 960 / v.videoWidth);
			c.id = 'cbx-poster'; c.width = Math.round(v.videoWidth * k); c.height = Math.round(v.videoHeight * k);
			c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
			P.shell.appendChild(c);
		} catch (e) {}
	}
	function sleepPlayer(why) {
		if (P.sleeping || !P.video || !P.eng) return;
		var v = P.video;
		// sound that really is playing in the background (a browser that allows it) is nobody's to cut
		if (why === 'background' && !v.paused && !v.muted) { log('sleep skipped: sound is playing'); return; }
		P.sleeping = true;
		P.wake = { muted: v.muted, live: tabWasLive };
		if (P.adopted) {
			// one player: the element is the site's and stays; the engine stops fetching and the picture holds its last frame
			try { if (P.eng.h) P.eng.h.stopLoad(); } catch (e) {}
			try { if (!v.paused) v.pause(); } catch (e) {}
		} else {
			showPoster(v);
			dropDvr({ keepParked: true });
		}
		log('player asleep (' + why + ')');
	}
	function wakePlayer(away) {
		if (!P.sleeping) return false;
		P.sleeping = false;
		log('player waking' + (away ? ' after ' + Math.round(away / 1000) + 's' : ''));
		if (P.adopted) {
			if (P.block) { P.block._cbxClips = null; }
			if (away > SLEEP_AFTER || !P.eng || !P.eng.h || A.dead) readopt('wake');
			else { try { P.eng.h.startLoad(-1); } catch (e) {} var pv = P.video; if (pv) { if (P.wake && P.wake.live) catchUpIfStale(pv); playOrMuted(pv); } }
		} else {
			P.suspended = false; P.attempt = 0;
			playerTick();
		}
		return true;
	}
	// sound after a rebuild: the new picture starts muted (a phone allows nothing else without a tap). If the
	// viewer had sound, try to give it back; a browser that answers by pausing gets a muted picture and a pill.
	function restoreSound(v) {
		var w = P.wake; P.wake = null;
		if (!w || w.muted || !v || !v.isConnected) return;
		try { v.muted = false; } catch (e) {}
		setTimeout(function () {
			if (v !== P.video || !v.isConnected) return;
			if (!v.paused && !v.muted) return;
			v.muted = true;
			var p = v.play(); if (p && p.catch) p.catch(function () {});
			showSoundPill(v);
		}, 450);
	}
	function showSoundPill(v) {
		if (!P.block || $('#cbx-soundpill', P.block)) return;
		var pill = el('button', { id: 'cbx-soundpill', type: 'button' }, I.muted + '<span>Tap for sound</span>');
		['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { pill.addEventListener(ev, function (e) { e.stopPropagation(); }); });
		pill.addEventListener('click', function (e) {
			e.preventDefault(); e.stopPropagation();
			userHasInteracted = true;
			var t = activeVideo(); if (t) { setMutedOurs(t, false); var p = t.play(); if (p && p.catch) p.catch(function () {}); }
			pill.remove(); updateBar();
		});
		P.block.appendChild(pill);
		var gone = function () { if (!v.muted && pill.isConnected) pill.remove(); };
		v.addEventListener('volumechange', gone);
		setTimeout(function () { if (pill.isConnected) pill.remove(); v.removeEventListener('volumechange', gone); }, 20000);
	}
	function realTime(v) { try { return timeDesc && timeDesc.get ? timeDesc.get.call(v) : v.currentTime; } catch (e) { return v.currentTime; } }
	function noteTabState() {
		var v = null, w = null;
		try { v = activeVideo(); w = v && seekWindow(v); } catch (e) {}
		// "was it playing" is "did the viewer pause it", nothing else (see the pause listener)
		tabWasPlaying = !P.userPaused;
		tabWasLive = !v || !w || w.end - realTime(v) < 20;
	}
	function noteTabHidden() { tabHidAt = Date.now(); resumeSeq++; noteTabState(); }
	function onTabShown(why) {
		if (IS_MULTI || WATCH_POP) return;
		tabShownAt = Date.now();
		// the page was laid out again behind our back while it was hidden: measure everything afresh, twice
		// (at once, and when the site's own pass has run)
		[350, 1600].forEach(function (ms) { setTimeout(function () {
			if (document.hidden || !P.block) return;
			P.block._cbxClips = null; if (P.box) P.box._cbxClips = null;
			assertPhoneLayout('tab shown'); healBoxHeight('tab shown'); fitBar(true); applyTransform(); placeLauncher();
		}, ms); });
		if (why === 'online') noteTabState();
		clearTimeout(resumeTimer);
		resumeTimer = setTimeout(function () { try { resumeCheck(why); } catch (e) { log('resume', e && e.message); } }, 300);
	}
	function sampleMoving(cb) {
		var v = activeVideo();
		if (!v) { cb(false, null); return; }
		var a = realTime(v);
		setTimeout(function () { cb(v.isConnected && !v.paused && v.readyState >= 3 && realTime(v) > a + 0.3, v); }, 1200);
	}
	function catchUpIfStale(v) {
		try {
			var h = v._cbxHls, w = seekWindow(v), live = h && h.liveSyncPosition != null ? h.liveSyncPosition : (w ? w.end - 1 : null);
			if (live != null && live - realTime(v) > 12) { log('resume: ' + Math.round(live - realTime(v)) + 's behind; going to live'); setTime(v, live); }
		} catch (e) {}
	}
	/* A phone does not let a page start sound by itself, and coming back to a tab is not a tap. A player
	 * that had sound was un-muted again on return, its play() was refused, and the way back then read
	 * "paused, has data" as "waiting for a tap" and stopped there: a frozen or blank picture that only a
	 * tap on play revived. A refused start now falls back to a muted picture that does play, with the
	 * "Tap for sound" pill, which is the most a page is allowed to do. */
	function playOrMuted(v) {
		if (!v || P.userPaused) return;
		var fallback = function (why) {
			if (!v.isConnected || !v.paused || P.userPaused || document.hidden) return;
			if (v.muted) return;
			log('play with sound was refused (' + why + '); starting muted');
			v.muted = true;
			var p2 = v.play(); if (p2 && p2.catch) p2.catch(function () {});
			showSoundPill(v); updateBar();
		};
		var p = null; try { p = v.play(); } catch (e) {}
		if (p && p.catch) p.catch(function (e) { if (e && e.name === 'NotAllowedError') fallback('not allowed'); });
		setTimeout(function () { if (v.paused && !v.muted) fallback('still paused'); }, 800);
	}
	function nudgeOurs(v, away) {
		var h = v._cbxHls;
		if (h) {
			try { h.startLoad(-1); } catch (e) {}
			if (tabWasLive || away > heldTarget() * 1000) {
				// the live position is only known again once the playlist has been re-read
				var did = false, go = function () { if (did || v !== P.video) return; did = true; catchUpIfStale(v); };
				try { var H = requiredHls(); if (H && H.Events) h.once(H.Events.LEVEL_UPDATED, go); } catch (e) {}
				setTimeout(go, 2500);
			}
		} else if (tabWasLive) catchUpIfStale(v);
		if (v.paused) playOrMuted(v);
	}
	function nudgeSite() {
		var site = P.site && P.site.isConnected ? P.site : siteVideo();
		kickSitePlay();
		if (!site || site._cbxClock) return;     // parked under our player: leave it be
		try {
			if (tabWasLive) catchUpIfStale(site);
			if (site.paused) { var p = site.play(); if (p && p.catch) p.catch(function () {}); }
		} catch (e) {}
	}
	function nudgePlayers(away) {
		var ours = P.video && P.video.isConnected ? P.video : null;
		if (ours) nudgeOurs(ours, away); else nudgeSite();
	}
	function resumeCheck(why) {
		if (!S.autoRecover || document.hidden || !roomName()) return;
		var away = tabHidAt ? Date.now() - tabHidAt : -1;
		var ctx = { seq: ++resumeSeq, user: roomName(), away: Math.max(0, away), nudged: false };
		tabHidAt = 0;
		log('tab shown (' + why + ')' + (away >= 0 ? ' after ' + Math.round(away / 1000) + 's' : '') + (P.sleeping ? ', player asleep' : '') + (tabWasPlaying ? '' : ', paused by the viewer'));
		if (!tabWasPlaying) return;               // the viewer paused it before leaving: it stays paused (the play button wakes a sleeping player)
		setTimeout(function () { var av = activeVideo(); if (av && av.paused && !P.sleeping && !P.userPaused && !document.hidden && (av === P.video || !P.video)) playOrMuted(av); }, 450);
		// phones: a long absence always ends in a fresh start, whether or not the sleep timer got to run
		// (a frozen tab runs no timers). The old session is not worth a diagnosis.
		var longAway = !!S.phoneSleep && !!S.autoRecover && isPhone() && ctx.away > SLEEP_AFTER && !!P.video && !!P.eng && !recording();
		if (P.sleeping || longAway) {
			if (!P.sleeping) sleepPlayer('late');
			if (wakePlayer(ctx.away)) {
				ctx.nudged = true;
				toast(tabWasLive ? 'Reconnecting…' : 'Reconnecting — the rewind window starts again', 2500);
				setTimeout(function () { resumeStep(1, ctx); }, 9000);
				return;
			}
		}
		playerTick();
		// a quick look at another tab needs no fixing; a first showing or a real absence gets the nudge right away
		if (away < 0 || away > 5000) { nudgePlayers(ctx.away); ctx.nudged = true; }
		setTimeout(function () { resumeStep(1, ctx); }, 2500);
	}
	function resumeStep(n, ctx) {
		var live = function () { return ctx.seq === resumeSeq && !document.hidden && roomName() === ctx.user; };
		if (!live()) return;
		sampleMoving(function (ok, v) {
			if (!live()) return;
			if (ok) { log('resume: the picture is moving'); return; }
			if (!ctx.nudged) { ctx.nudged = true; nudgePlayers(ctx.away); setTimeout(function () { resumeStep(1, ctx); }, 4000); return; }
			// a picture that has data but waits for a tap (autoplay refused) is not a dead stream
			if (v && v.paused && v.readyState >= 2) {
				if (!v.muted && !P.userPaused) { log('resume: paused with data and sound on; trying muted'); playOrMuted(v); setTimeout(function () { resumeStep(n, ctx); }, 2500); return; }
				log('resume: paused with data; waiting for a tap'); return;
			}
			if (n === 1) {
				// our own player can always be rebuilt from a fresh address: no page reload needed
				if (P.adopted) { log('resume: the engine is stuck; a fresh one'); A.dead = true; readopt('resume'); }
				else if (P.video && P.video.isConnected) { log('resume: our player is stuck; rebuilding it'); dropDvr(); } else nudgeSite();
				if (!P.forcedSite) { P.suspended = false; P.attempt = 0; }
				setTimeout(function () { if (live()) playerTick(); }, 400);
				setTimeout(function () { resumeStep(2, ctx); }, 9000);
				return;
			}
			lastResort(ctx.user);
		});
	}
	function lastResort(user) {
		hlsFor(user).then(function (url) {
			if (roomName() !== user || document.hidden) return;
			if (!url) { if (lastResort.told !== user) { lastResort.told = user; toast('This room is not broadcasting publicly right now'); } return; }
			var typing = document.activeElement && document.activeElement.matches && document.activeElement.matches('input,textarea,[contenteditable],[contenteditable] *');
			var last = 0; try { last = parseInt(sessionStorage.getItem('cbx-auto-reload'), 10) || 0; } catch (e) {}
			if (typing || Date.now() - last < 120000) { toast('The video did not come back — reload the page', 6000); return; }
			try { sessionStorage.setItem('cbx-auto-reload', String(Date.now())); } catch (e) {}
			log('resume: nothing worked; reloading the page');
			toast('The video did not come back — reloading…');
			setTimeout(function () { if (roomName() === user && !document.hidden) location.reload(); }, 900);
		}).catch(function () { toast(navigator.onLine === false ? 'No connection' : 'The video did not come back — reload the page', 6000); });
	}

	function installExclusiveAudio() {
		document.addEventListener('volumechange', function (e) {
			if (!S.exclusiveAudio) return;
			var v = e.target;
			if (v instanceof HTMLVideoElement && !v.muted && v.volume > 0) {
				broadcast({ type: 'mute-others', from: TAB_ID });
			}
		}, true);
	}

	/* ================================================================== *
	 * video transforms
	 * ================================================================== */

	var xform = { rot: 0, flip: 1, zoom: 1, panX: 0, panY: 0 };
	// picture filters live for the page only; a reload gives a clean picture
	var VF_DEFAULT = { bright: 100, contrast: 100, sat: 100, invert: false };
	var vf = {};
	for (var vfk in VF_DEFAULT) vf[vfk] = VF_DEFAULT[vfk];

	function filterCSS() {
		var parts = [];
		if (vf.bright !== 100) parts.push('brightness(' + vf.bright / 100 + ')');
		if (vf.contrast !== 100) parts.push('contrast(' + vf.contrast / 100 + ')');
		if (vf.sat !== 100) parts.push('saturate(' + vf.sat / 100 + ')');
		if (vf.invert) parts.push('invert(1)');
		return parts.join(' ');
	}
	function pictureTouched() {
		return xform.rot !== 0 || xform.flip !== 1 || xform.zoom !== 1 || !!filterCSS();
	}

	/* ---- geometry ----
	 * The picture is always contain-fit inside the video element, and every other fit is a scale
	 * on top of that. Filling a taller phone box used to be object-fit:cover, but a replaced
	 * element clips its own picture: dragging it slid an already-cropped picture around and showed
	 * black, never the part that was cut off, and "Fit" shrank the cropped picture instead of
	 * showing the whole frame. With a scale the whole frame is still there, outside the box, and
	 * the pan can reach every part of it. A turned picture (90°/270°) is measured turned, so it
	 * fits the box and pans correctly as well.
	 *
	 * W,H: the box. w1,h1: the picture on screen at CSS scale 1. fit: the scale that shows the whole
	 * frame. fill: the scale that leaves no bars. base: what zoom 1 means in this box (fill for a
	 * taller phone box, fit everywhere else), so xform.zoom and the per-room memory keep their meaning. */
	function measureFit() {
		var sh = P.shell; if (!sh) return null;
		var W = sh.clientWidth, H = sh.clientHeight; if (!W || !H) return null;
		var va = videoAspect() || 9 / 16, pw, ph;
		if (H / W > va) { pw = W; ph = W * va; } else { ph = H; pw = H / va; }
		var turned = xform.rot % 180 !== 0, w1 = turned ? ph : pw, h1 = turned ? pw : ph;
		var fit = Math.min(W / w1, H / h1), fill = Math.max(W / w1, H / h1);
		var cover = document.documentElement.classList.contains('cbx-ph') && H / W > h1 / w1 + 0.01;
		var ins = P.block && P.block._ins, VW = Math.max(40, W - (ins ? ins.il + ins.ir : 0)), VH = Math.max(40, H - (ins ? ins.it + ins.ib : 0));
		return { W: W, H: H, VW: VW, VH: VH, w1: w1, h1: h1, fit: fit, fill: fill, cover: cover, base: cover ? fill : fit };
	}
	// while a finger is down the box does not change: the geometry is measured once and reused, so
	// a drag or a pinch never reads layout (each read after a style write forced a reflow: the jank)
	var xfLive = null, xfLiveAt = 0, xfRaf = 0, glideRaf = 0;
	function geom() { return xfLive || measureFit(); }
	// how far the picture may be dragged before its edge would show
	// visible: measured against what is on screen (the site may hide the sides of its own box), which is
	// what a pan may reach. Without it: against the box, which is what decides whether a plain drag on the
	// picture pans (zoomed in) or stays a swipe-to-seek.
	function panLimitsFor(g, z, visible) {
		if (!g) return { x: 0, y: 0 };
		var sc = z * g.base, W = visible ? g.VW : g.W, H = visible ? g.VH : g.H;
		return { x: Math.max(0, (g.w1 * sc - W) / 2), y: Math.max(0, (g.h1 * sc - H) / 2) };
	}
	function panLimits() { return panLimitsFor(geom(), xform.zoom); }
	function clampPan(g) {
		var L = panLimitsFor(g || geom(), xform.zoom, true);
		xform.panX = Math.max(-L.x, Math.min(L.x, xform.panX || 0));
		xform.panY = Math.max(-L.y, Math.min(L.y, xform.panY || 0));
		return L;
	}
	// on phones a taller box shows the picture filled: the sides hang over the box, so it can pan too
	function coverCrops() { var g = geom(); return !!g && g.cover; }
	function panActive() { var L = panLimits(); return L.x > 0.5 || L.y > 0.5; }
	function transformCSS(g, live) {
		var sc = xform.zoom * (g ? g.base : 1), px = xform.panX || 0, py = xform.panY || 0;
		if (!live) { px = Math.round(px); py = Math.round(py); }     // whole pixels at rest: a crisper picture
		if (!px && !py && xform.rot === 0 && xform.flip === 1 && Math.abs(sc - 1) < 0.001) return '';
		return 'translate3d(' + (+px.toFixed(2)) + 'px,' + (+py.toFixed(2)) + 'px,0) rotate(' + xform.rot + 'deg) scaleX(' + xform.flip + ') scale(' + (+sc.toFixed(4)) + ')';
	}
	function applyTransform() {
		var v = activeVideo();
		if (!v) return;
		// a gesture is painting frame by frame: a full pass now would re-measure (a reflow) and write a
		// rounded pan that the next frame un-rounds again. A glide gives way; a finger keeps the picture.
		if (xfLive && glideRaf) { endLive(); return; }
		if (xfLive) {
			if (Date.now() - xfLiveAt < 1500) { queueLive(true); return; }
			// no gesture event for a while (a lost pointer, a slider that never sent 'change'): stand down
			xfLive = null; v.style.willChange = '';
		}
		var g = geom(); clampPan(g);
		var L = panLimitsFor(g, xform.zoom);
		var t = transformCSS(g, false);
		if (v.style.transform !== t) v.style.transform = t;
		if (t) v.style.transformOrigin = 'center center';
		if (P.block) {
			// zoomed: drags in every direction belong to the pan. panx: only the sides hang over (a filled
			// taller box), so a vertical drag still scrolls the page
			var zoomed = xform.zoom > 1.02 || L.y > 0.5;
			P.block.classList.toggle('cbx-cover', !!(g && g.cover));
			P.block.classList.toggle('cbx-zoomed', zoomed);
			P.block.classList.toggle('cbx-panx', !zoomed && L.x > 0.5);
		}
		var f = filterCSS();
		if (v.style.filter !== f) v.style.filter = f;
		syncBar();
	}
	/* ---- live painting: drags, pinches, the zoom slider ----
	 * Input events only move numbers; one requestAnimationFrame per frame writes the transform,
	 * and nothing else is touched until the gesture ends (then a full applyTransform tidies up). */
	function stopGlide() { if (glideRaf) { cancelAnimationFrame(glideRaf); glideRaf = 0; } }
	function beginLive() {
		stopGlide();
		xfLiveAt = Date.now();
		if (xfLive) return;
		xfLive = measureFit();
		var v = activeVideo(); if (v) v.style.willChange = 'transform';
	}
	function zoomKnob() {
		var inp = P.block && $('#cbx-zoom', P.block); if (!inp || !inp.parentElement) return;
		var lo = parseFloat(inp.min) || 0, hi = parseFloat(inp.max) || 100, f = Math.max(0, Math.min(1, ((parseFloat(inp.value) || lo) - lo) / Math.max(1, hi - lo))).toFixed(3);
		if (inp._z !== f) { inp._z = f; inp.parentElement.style.setProperty('--cbx-z', f); }
	}
	function liveLabel() {
		if (!P.block) return;
		var R = P.block._zrefs || (P.block._zrefs = { val: $('#cbx-zoom-val', P.block), inp: $('#cbx-zoom', P.block) });
		var txt = zoomLabel();
		if (R.val && R.val.textContent !== txt) R.val.textContent = txt;
		if (R.inp && document.activeElement !== R.inp) { var zv = String(Math.round(effZoom() * 100)); if (R.inp.value !== zv) R.inp.value = zv; }
		zoomKnob();
	}
	function paintLive() {
		xfRaf = 0;
		var v = activeVideo(), g = xfLive;
		if (!v || !g) return;
		clampPan(g);
		var t = transformCSS(g, true);
		if (v.style.transform !== t) { v.style.transform = t; v.style.transformOrigin = 'center center'; }
		liveLabel();
	}
	function queueLive(passive) {
		if (!passive) xfLiveAt = Date.now();
		if (!xfLive) beginLive();
		if (!xfRaf) xfRaf = requestAnimationFrame(paintLive);
	}
	function endLive() {
		stopGlide();
		if (xfRaf) { cancelAnimationFrame(xfRaf); xfRaf = 0; }
		if (!xfLive) return;
		xfLive = null;
		var v = activeVideo(); if (v) v.style.willChange = '';
		applyTransform();
	}
	// after a flick the picture keeps moving and eases out; an edge stops it on that axis
	function glide(vx, vy) {
		var reduce = false; try { reduce = matchMedia('(prefers-reduced-motion:reduce)').matches; } catch (e) {}
		if (reduce || !xfLive) { endLive(); return; }
		var last = performance.now();
		var step = function (now) {
			var dt = Math.max(1, Math.min(34, now - last)); last = now;
			var bx = xform.panX, by = xform.panY;
			xform.panX += vx * dt; xform.panY += vy * dt;
			clampPan(xfLive);
			if (Math.abs(xform.panX - bx) < 0.01) vx = 0;
			if (Math.abs(xform.panY - by) < 0.01) vy = 0;
			var k = Math.pow(0.994, dt); vx *= k; vy *= k;
			xfLiveAt = Date.now();
			paintLive();
			if (Math.abs(vx) < 0.02 && Math.abs(vy) < 0.02) { glideRaf = 0; endLive(); return; }
			glideRaf = requestAnimationFrame(step);
		};
		glideRaf = requestAnimationFrame(step);
	}
	// the scale at which the picture just covers the box (letterbox gone), relative to Fit
	function coverScale() { var g = geom(); return g ? g.fill / g.fit : 1; }
	// the zoom the viewer sees, relative to the whole frame: a filled box already counts as Fill
	function coverFactor() { var g = geom(); return g ? g.base / g.fit : 1; }
	function effZoom() { return xform.zoom * coverFactor(); }
	function setEffZoom(z, live) {
		if (live) beginLive();
		xform.zoom = Math.max(0.5, Math.min(3, z)) / coverFactor();
		if (live) queueLive(); else applyTransform();
	}
	function zoomLabel() {
		var z = effZoom(), c = coverScale();
		if (Math.abs(z - 1) < 0.03) return 'Fit';
		if (c > 1.03 && Math.abs(z - c) < 0.03) return 'Fill';
		return z.toFixed(1) + '×';
	}
	// Fit -> Fill -> 1.5x -> 2x -> Fit
	function roomZoom() { var m = S.zoomByRoom || {}, r = roomName(); var z = r && m[r]; return z && z > 0.5 && z <= 3 ? z : 1; }
	function rememberZoom() {
		var r = roomName(); if (!r) return;
		var m = S.zoomByRoom || (S.zoomByRoom = {});
		if (Math.abs(xform.zoom - 1) < 0.03) delete m[r]; else m[r] = Math.round(xform.zoom * 100) / 100;
		save();
	}
	function cycleZoom() {
		var c = coverScale(), steps = [1]; if (c > 1.03 && c < 1.45) steps.push(c); steps.push(1.5, 2);
		var z = effZoom(), i = 0; while (i < steps.length && steps[i] <= z + 0.03) i++;
		setEffZoom(steps[i % steps.length]); rememberZoom(); toast('Zoom ' + zoomLabel());
	}
	function resetTransform() {
		stopGlide(); if (xfRaf) { cancelAnimationFrame(xfRaf); xfRaf = 0; } xfLive = null;
		xform = { rot: 0, flip: 1, zoom: 1, panX: 0, panY: 0 };
		if (P.block) { P.block.classList.remove('cbx-zoomed'); P.block.classList.toggle('cbx-panx', coverCrops()); }
		for (var k in VF_DEFAULT) vf[k] = VF_DEFAULT[k];
		var v = activeVideo();
		if (v) { v.style.transform = ''; v.style.filter = ''; }
		syncBar();
	}

	/* ================================================================== *
	 * volume boost + voice boost
	 *
	 * The cam audio is routed through a small Web Audio graph:
	 * source → high-pass → low-mid cut → presence lift → gain → out.
	 * The three filters are flat unless voice boost is on. A media element
	 * can only be wired once, so the graph is kept per video.
	 * ================================================================== */

	var chains = new WeakMap(), sharedCtx = null, chained = [];

	/* One AudioContext for the page. There used to be one per <video>, never closed. The overlay player
	 * makes a new <video> every time it is rebuilt (each return to a tab on a phone, each recovery), so
	 * with Boost or levelling on, every rebuild left a running audio context behind; browsers allow only a
	 * handful, and after that the next one is refused and the sound options stop working until a reload.
	 * Closing a context per video would be the obvious fix and a trap: a media element that was routed
	 * into a context once is routed for good, and closing that context leaves it silent for ever. The
	 * site's own video lives on after we let go of it, so its context may never be closed. With a single
	 * shared context the question does not come up: a discarded video only has its few nodes unplugged. */
	function audioCtx() {
		var Ctx = window.AudioContext || window.webkitAudioContext;
		if (!Ctx) return null;
		if (sharedCtx && sharedCtx.state !== 'closed') return sharedCtx;
		try { sharedCtx = new Ctx(); } catch (e) { log('audio context refused:', e && e.message); sharedCtx = null; }
		return sharedCtx;
	}
	// a video of ours that is thrown away: unplug its nodes so they (and it) can be collected
	function unchain(v) {
		var ch = v && chains.get(v);
		if (v) { chains.delete(v); chained = chained.filter(function (c) { return c.v !== v; }); }
		if (!ch) return;
		[ch.src, ch.hp, ch.mid, ch.pres, ch.comp, ch.gain, ch.lim].forEach(function (n) { try { if (n) n.disconnect(); } catch (e) {} });
		try { ch.dest.stream.getTracks().forEach(function (t) { t.stop(); }); } catch (e) {}
	}
	// the site's own video is never unplugged while it is on the page (it would fall silent). One that has
	// been off the page for a minute is not coming back.
	function sweepChains() {
		var now = Date.now();
		chained.slice().forEach(function (c) {
			if (c.v.isConnected) { c.goneAt = 0; return; }
			if (!c.goneAt) { c.goneAt = now; return; }
			if (now - c.goneAt > 60000) { log('sound: a video that left the page a minute ago is unplugged'); unchain(c.v); }
		});
	}

	/* With one player the routed video is the site's, and it goes back to the site's own native stream
	 * when we let go (the Site pill, a fallback, a room that ends). A native cross-origin stream that
	 * runs through Web Audio is silence unless the element asked for it with CORS. The stream server
	 * answers CORS (hls.js could not fetch from it otherwise), so a routed video asks for it. */
	function keepRoutedAudible(v, url) {
		try { if (v && v._cbxAudioSrc && !v.crossOrigin && url && !/^blob:/i.test(String(url))) { v.crossOrigin = 'anonymous'; log('sound: this video is routed through the page; its native stream is asked for with CORS'); } } catch (e) {}
	}
	function audioChain(v) {
		if (!v) return null;
		if (chains.has(v)) return chains.get(v);
		// a cross-origin src that is not MSE is tainted: routing it would give silence. (A video that is
		// routed already has no choice: see keepRoutedAudible.)
		if (!v._cbxAudioSrc && !/^blob:/.test(v.currentSrc || v.src || '') && !v.srcObject) return null;
		var ctx = audioCtx();
		if (!ctx) return null;
		try {
			// A media element can be given a source node once in its life; a second attempt throws, and the
			// element stays routed into the first one. Ours is therefore kept on the element: a video that was
			// unplugged (it left the page for a minute) and came back is plugged in again, not left silent.
			var src = v._cbxAudioSrc || null;
			if (src && src.context !== ctx) { log('sound: this video is routed into an audio context that is gone'); chains.set(v, null); return null; }
			if (!src) { src = ctx.createMediaElementSource(v); v._cbxAudioSrc = src; }
			var hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 10; hp.Q.value = 0.7;
			var mid = ctx.createBiquadFilter(); mid.type = 'peaking'; mid.frequency.value = 300; mid.Q.value = 1; mid.gain.value = 0;
			var pres = ctx.createBiquadFilter(); pres.type = 'peaking'; pres.frequency.value = 3000; pres.Q.value = 0.9; pres.gain.value = 0;
			var gain = ctx.createGain();
			var dest = ctx.createMediaStreamDestination();
			src.connect(hp); hp.connect(mid); mid.connect(pres);
			/* "Even out the volume". A compressor pulls loud moments (tip sounds, music, a shout into the
			 * microphone) down towards the quiet ones and its built-in make-up gain lifts the whole back up,
			 * so a whisper and a scream end up much closer together. It sits before the boost; a hard limiter
			 * after the boost keeps "levelled and boosted to 300%" from clipping. Neither is in the path while
			 * the option is off (routeChain), so nothing changes for anyone who does not ask for it. */
			var comp = null, lim = null;
			if (ctx.createDynamicsCompressor) {
				comp = ctx.createDynamicsCompressor();
				comp.threshold.value = -32; comp.knee.value = 24; comp.ratio.value = 6; comp.attack.value = 0.004; comp.release.value = 0.28;
				lim = ctx.createDynamicsCompressor();
				lim.threshold.value = -2; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.001; lim.release.value = 0.12;
			}
			var ch = { ctx: ctx, src: src, gain: gain, hp: hp, mid: mid, pres: pres, dest: dest, comp: comp, lim: lim, normOn: null };
			routeChain(ch);
			chains.set(v, ch);
			chained.push({ v: v, goneAt: 0 });
			return ch;
		} catch (e) {
			log('audio chain refused', e && e.message);
			chains.set(v, null);
			return null;
		}
	}

	function soundShaped() { return S.volumeBoost !== 100 || !!S.voiceBoost || !!S.normalize; }
	function routeChain(ch) {
		var on = !!S.normalize && !!ch.comp;
		if (ch.normOn === on) return;
		ch.normOn = on;
		try { ch.pres.disconnect(); ch.gain.disconnect(); if (ch.comp) { ch.comp.disconnect(); ch.lim.disconnect(); } } catch (e) {}
		if (on) { ch.pres.connect(ch.comp); ch.comp.connect(ch.gain); ch.gain.connect(ch.lim); ch.lim.connect(ch.ctx.destination); ch.lim.connect(ch.dest); }
		else { ch.pres.connect(ch.gain); ch.gain.connect(ch.ctx.destination); ch.gain.connect(ch.dest); }
	}
	/* Sound that is shaped runs through an AudioContext, and a phone suspends that on its own (a tab in
	 * the background, a call, Siri) and lets only a tap resume it: a picture that plays in silence. Any
	 * tap on the page resumes it; coming back to a tab tries without one, and if the phone says no, the
	 * "Tap for sound" pill says what is needed. */
	function resumeAudio(fromTap) {
		var v = activeVideo(), ch = v && chains.get(v);
		if (!ch || !ch.ctx || ch.ctx.state === 'running' || ch.ctx.state === 'closed') return;
		var was = ch.ctx.state, p = null;
		try { p = ch.ctx.resume(); } catch (e) {}
		var check = function () {
			if (ch.ctx.state === 'running') { log('sound: audio context was ' + was + '; resumed' + (fromTap ? ' by a tap' : '')); var pill = $('#cbx-soundpill'); if (pill && !v.muted) pill.remove(); }
			else if (!fromTap && !v.muted && !v.paused && !document.hidden) { log('sound: audio context is ' + ch.ctx.state + ' and needs a tap'); showSoundPill(v); }
		};
		if (p && p.then) p.then(check, check); else setTimeout(check, 200);
	}
	['pointerdown', 'touchend', 'keydown'].forEach(function (ev) { document.addEventListener(ev, function () { resumeAudio(true); }, { capture: true, passive: true }); });
	document.addEventListener('visibilitychange', function () { if (!document.hidden) setTimeout(function () { resumeAudio(false); }, 600); });
	function applyAudioChain() {
		var v = activeVideo();
		if (!v) return;
		var wanted = soundShaped();
		// a video that was routed once has no other way to the speakers: it gets its chain (back) even when
		// nothing is shaped any more, as a straight pass-through
		if (!wanted && !chains.has(v) && !v._cbxAudioSrc) return;
		var ch = audioChain(v);
		if (!ch) return;
		if (ch.ctx.state === 'suspended' && userHasInteracted) { var p = ch.ctx.resume(); if (p && p.catch) p.catch(function () {}); }
		routeChain(ch);
		ch.gain.gain.value = Math.max(0, S.volumeBoost) / 100;
		ch.hp.frequency.value = S.voiceBoost ? 120 : 10;
		ch.mid.gain.value = S.voiceBoost ? -6 : 0;
		ch.pres.gain.value = S.voiceBoost ? 5 : 0;
	}

	/* ================================================================== *
	 * record what is playing
	 * ================================================================== */

	var rec = { r: null, chunks: [], since: 0, timer: null, kind: 'video' };

	function recMime(audioOnly) {
		var list = audioOnly
			? ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']
			: ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
		for (var i = 0; i < list.length; i++) if (window.MediaRecorder && MediaRecorder.isTypeSupported(list[i])) return list[i];
		return '';
	}

	function recording() { return !!(rec.r && rec.r.state === 'recording'); }

	function startRecording() {
		var v = activeVideo();
		if (!v || v.readyState < 2) { toast('Nothing is playing to record'); return; }
		if (!window.MediaRecorder) { toast('This browser cannot record'); return; }
		var cap = v.captureStream ? v.captureStream() : (v.mozCaptureStream ? v.mozCaptureStream() : null);
		if (!cap) { toast('This browser cannot capture the player'); return; }
		var tracks = [];
		if (!S.recAudioOnly) tracks = tracks.concat(cap.getVideoTracks());
		if (!S.recVideoOnly) {
			var ch = chains.get(v);
			tracks = tracks.concat(ch ? ch.dest.stream.getAudioTracks() : cap.getAudioTracks());
		}
		if (!tracks.length) { toast('Nothing to record with those options'); return; }
		var stream = new MediaStream(tracks);
		var audioOnly = !stream.getVideoTracks().length;
		var opts = { mimeType: recMime(audioOnly) };
		if (!opts.mimeType) delete opts.mimeType;
		if (S.recLowPerf && !audioOnly) opts.videoBitsPerSecond = 1200000;
		try { rec.r = new MediaRecorder(stream, opts); }
		catch (e) { toast('Recording could not start'); rec.r = null; return; }
		rec.chunks = []; rec.since = Date.now(); rec.kind = audioOnly ? 'audio' : 'video';
		rec.r.ondataavailable = function (e) { if (e.data && e.data.size) rec.chunks.push(e.data); };
		rec.r.onstop = finishRecording;
		rec.r.onerror = function () { toast('Recording stopped by the browser'); };
		rec.r.start(1000);
		addTick('rec', syncBar, 1000);
		syncBar();
		toast('Recording');
	}

	function stopRecording() {
		if (!rec.r) return;
		try { if (rec.r.state !== 'inactive') rec.r.stop(); } catch (e) { finishRecording(); }
	}

	function finishRecording() {
		removeTick('rec'); rec.timer = null;
		var r = rec.r; rec.r = null;
		var chunks = rec.chunks; rec.chunks = [];
		syncBar();
		if (!chunks.length) { toast('Nothing was recorded'); return; }
		var type = (r && r.mimeType) || (rec.kind === 'audio' ? 'audio/webm' : 'video/webm');
		var ext = /mp4/.test(type) ? (rec.kind === 'audio' ? 'm4a' : 'mp4') : (/ogg/.test(type) ? 'ogg' : 'webm');
		var blob = new Blob(chunks, { type: type });
		var a = el('a', { download: (roomName() || 'cam') + '-' + new Date().toISOString().replace(/[:.]/g, '-') + '.' + ext });
		a.href = URL.createObjectURL(blob); document.body.appendChild(a); a.click(); a.remove();
		setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
		toast('Saved ' + Math.round(blob.size / 1048576) + ' MB');
	}

	function toggleRecording() { if (recording()) stopRecording(); else startRecording(); }

	window.addEventListener('beforeunload', function (e) {
		if (!recording() || !S.recWarnLeave) return;
		e.preventDefault(); e.returnValue = '';
	});

	/* ================================================================== *
	 * copy stream URL
	 * ================================================================== */

	function copyStreamUrl() {
		var user = roomName();
		if (!user) { toast('You are not in a room'); return; }
		hlsFor(user).then(function (url) {
			if (!url) { toast('No stream URL — the room may be offline'); return; }
			if (navigator.clipboard && navigator.clipboard.writeText) {
				navigator.clipboard.writeText(url).then(function () { toast('Stream URL copied'); },
					function () { prompt('Stream URL', url); });
			} else prompt('Stream URL', url);
		}).catch(function () { toast('Could not fetch the stream URL'); });
	}

	/* ================================================================== *
	 * who in chat is broadcasting
	 * ================================================================== */

	var scanning = false;

	function scanChat() {
		if (scanning) return;
		scanning = true;
		var names = {};
		$$('div[data-testid="chat-message"]').forEach(function (m) {
			var u = m.querySelector('[data-testid="chat-message-username"],.username,a[href^="/"]');
			if (!u) return;
			var name = (u.textContent || '').trim().replace(/[:\s]+$/, '').toLowerCase();
			if (/^[a-z0-9_]{3,}$/.test(name)) names[name] = u;
		});
		var list = Object.keys(names).slice(0, 25);
		if (!list.length) { scanning = false; toast('No chat names found'); return; }
		toast('Checking ' + list.length + ' chatters…');

		var found = 0, i = 0;
		function next() {
			if (i >= list.length) {
				scanning = false;
				toast(found ? found + ' of them are broadcasting' : 'None of them are live');
				return;
			}
			var name = list[i++];
			hlsFor(name).then(function (url) {
				if (url && names[name] && names[name].isConnected) {
					found++;
					if (!names[name].querySelector('.cbx-livedot')) {
						names[name].appendChild(el('span', { 'class': 'cbx-livedot', title: 'Live now' }, '● live'));
					}
				}
			}).catch(function () {}).then(function () { setTimeout(next, 140); });
		}
		next();
	}

	/* ================================================================== *
	 * status alerts for a watch list
	 * ================================================================== */

	var alertTimer = null, alertState = {};

	function alertList() { return jsonGet(WATCH_KEY, []); }

	function toggleAlertFor(user) {
		var l = alertList(), i = l.indexOf(user);
		if (i === -1) l.push(user); else l.splice(i, 1);
		jsonSet(WATCH_KEY, l);
		refreshPanel();
		toast(i === -1 ? 'Alerting for ' + user : 'No longer alerting for ' + user);
		if (l.length && S.alertsOn) startAlerts();
	}

	function notify(text) {
		toast(text);
		broadcast({ type: 'alert', from: TAB_ID, text: text });
		try {
			if (window.Notification && Notification.permission === 'granted') new Notification('Chaturbate Enhanced Plus', { body: text });
		} catch (e) {}
	}

	function checkAlerts() {
		var list = alertList();
		if (!S.alertsOn || !list.length) return;
		var i = 0;
		function next() {
			if (i >= list.length) return;
			var user = list[i++];
			hlsFor(user).then(function (url) {
				var online = !!url;
				if (online && alertState[user] === false) notify(user + ' is online');
				alertState[user] = online;
			}).catch(function () {}).then(function () { setTimeout(next, 500); });
		}
		next();
	}

	function startAlerts() {
		clearInterval(alertTimer);
		if (!S.alertsOn) return;
		try {
			if (window.Notification && Notification.permission === 'default') Notification.requestPermission();
		} catch (e) {}
		checkAlerts();
		alertTimer = setInterval(checkAlerts, Math.max(30, S.alertEvery) * 1000);
	}

	/* ================================================================== *
	 * schedule: when has this room been online before
	 * ================================================================== */

	// each cell is [times seen, last date counted] so a day counts once per hour
	function seenCell(v) {
		if (Array.isArray(v)) return v;
		return v ? [1, ''] : [0, ''];
	}
	function recordSeen(user) {
		if (!S.trackSchedule || !user) return;
		var d = new Date(), key = d.getDay() + '-' + d.getHours();
		var ymd = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate();
		var all = jsonGet(SEEN_KEY, {});
		if (!all[user]) all[user] = {};
		var cell = seenCell(all[user][key]);
		if (cell[1] === ymd) return;
		all[user][key] = [cell[0] + 1, ymd];
		var keys = Object.keys(all);
		if (keys.length > 400) delete all[keys[0]];
		jsonSet(SEEN_KEY, all);
	}

	var DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

	function hourLabel(h) {
		if (S.time24) return ('0' + h).slice(-2) + ':00';
		return (h % 12 || 12) + (h < 12 ? ' am' : ' pm');
	}

	function scheduleHTML(user) {
		var all = jsonGet(SEEN_KEY, {}), grid = (user && all[user]) || null;
		if (!grid) return '<p class="cbx-note">Nothing recorded yet. Visit the room while it is live and the grid fills in.</p>';
		var max = 0, d, h, k;
		for (k in grid) max = Math.max(max, seenCell(grid[k])[0]);
		var now = new Date(), nowKey = now.getDay() + '-' + now.getHours();
		var out = '<div class="cbx-sched">';
		for (d = 0; d < 7; d++) {
			out += '<div class="cbx-sched-row"><b>' + DAYS[d] + '</b>';
			for (h = 0; h < 24; h++) {
				k = d + '-' + h;
				var n = seenCell(grid[k])[0];
				var lvl = n ? Math.max(1, Math.ceil(n / max * 4)) : 0;
				out += '<i class="' + (lvl ? 'cbx-on cbx-l' + lvl : '') + (k === nowKey ? ' cbx-now' : '') + '" title="' +
					DAYS[d] + ' ' + hourLabel(h) + (n ? ' · live ' + n + (n === 1 ? ' time' : ' times') : ' · never seen live') + '"></i>';
			}
			out += '</div>';
		}
		out += '<div class="cbx-sched-row cbx-sched-hours"><b></b>';
		for (h = 0; h < 24; h++) out += '<i>' + (h % 6 === 0 ? hourLabel(h).replace(':00', '').replace(' ', '') : '') + '</i>';
		out += '</div></div><p class="cbx-note">Darker means seen live on more days at that hour. The outlined cell is now.</p>';
		return out;
	}

	/* ================================================================== *
	 * diagnostics
	 *
	 * A phone has no console. The log is kept in memory (see log()), and
	 * this puts it together with everything a layout or player bug report
	 * needs: what the screen really measures, how the script started, what
	 * the player is doing, and what the site's player is built from.
	 * ================================================================== */

	function copyText(txt, okMsg, onFail) {
		var ok = function () { toast(okMsg || 'Copied', 4000); };
		var byHand = function () { toast('Could not copy by itself — the text is shown below, select it by hand', 6000); if (onFail) onFail(); };
		var legacy = function () {
			try {
				var ta = el('textarea'); ta.value = txt; ta.setAttribute('readonly', '');
				ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;font-size:16px';
				(uiHost() || document.body).appendChild(ta);
				ta.focus(); ta.select(); try { ta.setSelectionRange(0, txt.length); } catch (e) {}
				var done = document.execCommand('copy'); ta.remove();
				if (done) ok(); else byHand();
			} catch (e) { byHand(); }
		};
		try {
			if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(ok, legacy);
			else legacy();
		} catch (e) { legacy(); }
	}
	function rectStr(r) { return Math.round(r.width) + 'x' + Math.round(r.height) + '@' + Math.round(r.left) + ',' + Math.round(r.top); }
	function nodeLine(n, depth) {
		var r = n.getBoundingClientRect(), cs = getComputedStyle(n);
		var cls = typeof n.className === 'string' ? n.className.trim().split(/\s+/).filter(Boolean).slice(0, 4).join('.') : '';
		var label = (n.tagName === 'BUTTON' || n.getAttribute('role') === 'button') ? ' "' + (n.getAttribute('aria-label') || n.title || (n.textContent || '').trim()).slice(0, 24) + '"' : '';
		return new Array(depth + 1).join('  ') + n.tagName.toLowerCase() + (n.id ? '#' + n.id : '') + (cls ? '.' + cls : '') + ' ' + rectStr(r) + ' ' + cs.position +
			(cs.zIndex !== 'auto' ? ' z' + cs.zIndex : '') + (cs.transform && cs.transform !== 'none' ? ' TRANSFORM' : '') + (cs.filter && cs.filter !== 'none' ? ' FILTER' : '') +
			(cs.overflow !== 'visible' ? ' ov:' + cs.overflow : '') + (cs.display === 'none' ? ' display:none' : '') + (cs.visibility === 'hidden' ? ' hidden' : '') + (parseFloat(cs.opacity) < 0.05 ? ' opacity:0' : '') +
			(cs.pointerEvents === 'none' ? ' pe:none' : '') + (n.hasAttribute('data-cbx-sitectl') ? ' [HIDDEN BY US]' : '') + (n.getAttribute('data-testid') ? ' [testid=' + n.getAttribute('data-testid') + ']' : '') + label;
	}
	function playerStructure() {
		var v = siteVideo(); if (!v) return ['no site video on this page'];
		var lines = [], chain = [], n = v;
		for (var i = 0; i < 8 && n && n !== document.body && n !== document.documentElement; i++) { chain.unshift(n); n = n.parentElement; }
		lines.push('ancestors of the site video, outermost first:');
		chain.forEach(function (c, d) { lines.push(nodeLine(c, d + 1)); });
		var root = P.box && P.box.isConnected ? P.box : chain[Math.max(0, chain.length - 3)];
		lines.push('inside ' + root.tagName.toLowerCase() + (root.id ? '#' + root.id : '') + ':');
		(function walk(node, d) {
			if (d > 5) return;
			for (var c = node.firstElementChild; c; c = c.nextElementSibling) {
				if (lines.length > 110) { lines.push('  …'); return; }
				if (c.id === 'cbx-block') { lines.push(new Array(d + 1).join('  ') + '#cbx-block (ours) ' + rectStr(c.getBoundingClientRect()) + ' ' + c.className); continue; }
				if (c.tagName === 'SCRIPT' || c.tagName === 'STYLE') continue;
				lines.push(nodeLine(c, d)); walk(c, d + 1);
			}
		})(root, 1);
		var sib = root.nextElementSibling; if (sib) lines.push('next to it: ' + nodeLine(sib, 0));
		var src = ''; try { src = (srcDesc && srcDesc.get ? srcDesc.get.call(v) : v.src) || v.currentSrc || ''; } catch (e) {}
		lines.push('site video: src=' + (/^blob:/.test(src) ? 'blob' : src ? src.replace(/^https?:\/\/[^/]+/, '').replace(/\?.*$/, '').slice(-40) : 'none') + ' controls=' + v.controls + ' playsinline=' + v.hasAttribute('playsinline') +
			' autoplay=' + v.autoplay + ' muted=' + v.muted + ' paused=' + v.paused + ' ready=' + v.readyState + ' ' + v.videoWidth + 'x' + v.videoHeight + ' sources=' + v.querySelectorAll('source').length);
		return lines;
	}
	function diagReport() {
		var L = [], de = document.documentElement, vv = window.visualViewport, user = roomName();
		var add = function (s) { L.push(s); };
		var safe = function (fn) { try { return fn(); } catch (e) { return '! ' + (e && e.message); } };
		add('Chaturbate Enhanced Plus ' + VERSION + ' — diagnostics, ' + new Date().toISOString());
		add('UA: ' + navigator.userAgent);
		add('page: ' + (user ? 'room' : isHomePath() ? 'home' : 'other') + (IS_MULTI ? ' (multi cam)' : '') + ' · touch layout=' + wantsTouchLayout() + ' phone=' + safe(isPhone) + ' iOS=' + safe(isIOS) + ' standalone=' + !!navigator.standalone);
		add('');
		add('— screen —');
		add('window ' + innerWidth + 'x' + innerHeight + ' · html ' + de.clientWidth + 'x' + de.clientHeight + ' · scroll ' + de.scrollWidth + 'x' + de.scrollHeight + ' at ' + Math.round(scrollX) + ',' + Math.round(scrollY) +
			' · screen ' + screen.width + 'x' + screen.height + ' · dpr ' + devicePixelRatio);
		add('visualViewport ' + (vv ? Math.round(vv.width) + 'x' + Math.round(vv.height) + ' offset ' + Math.round(vv.offsetLeft) + ',' + Math.round(vv.offsetTop) + ' scale ' + (vv.scale || 1).toFixed(2) : 'none') +
			(de.scrollWidth > de.clientWidth + 1 ? ' · PAGE OVERFLOWS SIDEWAYS by ' + (de.scrollWidth - de.clientWidth) + 'px' : '') + (vv && vv.scale > 1.01 ? ' · PAGE IS ZOOMED' : ''));
		add(safe(function () {
			var p = el('div'); p.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;visibility:hidden;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)';
			document.body.appendChild(p); var cs = getComputedStyle(p), out = 'safe area t/r/b/l ' + [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].join(' '); p.remove();
			var mv = $('meta[name="viewport"]'); return out + ' · meta viewport: ' + (mv ? mv.getAttribute('content') : 'none');
		}));
		add('orientation ' + safe(function () { return (screen.orientation && screen.orientation.type) || window.orientation; }) + ' · html classes: ' + (de.className || '-'));
		add('fullscreen: element API=' + safe(canElementFs) + ' video-only API=' + safe(nativeFsAvailable) + ' · now: ' + (playerIsFs() ? 'element' : de.classList.contains('cbx-land') ? 'filled' : 'no'));
		add('');
		add('— player —');
		add('MSE=' + !!window.MediaSource + ' MMS=' + !!window.ManagedMediaSource + ' hls.js=' + safe(function () { var H = requiredHls(); return H ? (H.version || 'yes') : 'not loaded'; }) +
			' · rewind=' + S.siteDvr + ' engine=' + S.engine + ' · site player is ' + safe(function () { var m = siteIsMse(); return m === true ? 'MSE' : m === false ? 'native' : 'unknown'; }));
		add('state: video=' + (P.video ? (P.adopted ? 'adopted site video' : 'ours') : 'none') + ' armed=' + !!P.armed + ' busy=' + !!P.busy + ' suspended=' + !!P.suspended + ' forcedSite=' + !!P.forcedSite + ' sleeping=' + !!P.sleeping +
			' userPaused=' + !!P.userPaused + ' attempt=' + (P.attempt || 0) + ' · adopt: on=' + A.on + ' blocked=' + A.blocked + (A.why ? ' (' + A.why + ')' : '') + ' fails=' + adoptFails());
		add(safe(function () {
			var v = activeVideo(); if (!v) return 'active video: none';
			var w = seekWindow(v);
			return 'active video: ' + v.videoWidth + 'x' + v.videoHeight + ' paused=' + v.paused + ' muted=' + v.muted + ' ready=' + v.readyState + ' t=' + v.currentTime.toFixed(1) + ' buffered=[' + ranges(v.buffered).join(' ') + ']' +
				(w ? ' window=' + Math.round(w.end - w.start) + 's behind=' + Math.round(w.end - v.currentTime) + 's' : '') + ' seekable=' + safe(function () { return v.seekable.length ? Math.round(v.seekable.end(v.seekable.length - 1) - v.seekable.start(0)) + 's' : '0'; });
		}));
		add('note: ' + safe(function () { return String(siteDvrNote()).replace(/\n/g, ' | '); }));
		add(safe(function () {
			var b = P.block, bar = P.bar; if (!b || !bar) return 'bar: no block mounted';
			var row = $('.cbx-row-btns', bar), fs = $('button[data-act="fs"]', bar), br = b.getBoundingClientRect(), fr = fs ? fs.getBoundingClientRect() : null;
			var vis = visibleRect(b, false);
			return 'block ' + rectStr(br) + ' visible ' + Math.round(vis.width) + 'x' + Math.round(vis.height) + '@' + Math.round(vis.left) + ',' + Math.round(vis.top) + ' insets=' + (b._insets || '-') + ' classes: ' + b.className + '\nbar tier=' + (bar._tier || '?') + ' row needs ' + (row ? row.scrollWidth : '?') + ' has ' + (row ? row.clientWidth : '?') +
				' · fullscreen button ' + (fr && fr.width ? rectStr(fr) + (fr.right > br.right + 1 || fr.right > viewportBox().x + viewportBox().w + 1 ? ' CLIPPED' : ' visible') : 'not shown in this tier') +
				' · box ' + (P.box ? rectStr(P.box.getBoundingClientRect()) : '-');
		}));
		add('menu: ' + (ddOpen ? ddOpen + ' ' + safe(function () { return rectStr(ddEl.getBoundingClientRect()) + ' ' + ddEl.className; }) : 'closed') + ' · back stack: [' + backStack.map(function (x) { return x.id; }).join(',') + ']');
		add('');
		add('— site player structure —');
		safe(function () { playerStructure().forEach(add); return ''; });
		add('');
		add('— log (' + LOGBUF.length + ' lines, newest last) —');
		LOGBUF.forEach(add);
		var out = L.join('\n');
		// the room and anything after a "?" are nobody's business but the viewer's
		if (user) out = out.split(user).join('<room>');
		return out.replace(/(https?:\/\/[^\s"'<>?]+)\?[^\s"'<>]*/g, '$1?…');
	}

	/* ================================================================== *
	 * panel language
	 *
	 * Rather than shipping 45 hand-written string tables, the panel is
	 * translated on demand and cached, so any language the endpoint knows
	 * is available and nothing goes stale when a label changes.
	 * ================================================================== */

	function uiLangResolved() {
		if (S.uiLang && S.uiLang !== 'auto') return S.uiLang;
		var n = (navigator.language || 'en').toLowerCase();
		if (n.indexOf('zh') === 0) return 'zh-CN';
		return n.split('-')[0];
	}

	var i18nBusy = false;

	function localizePanel() {
		var lang = uiLangResolved();
		if (!panelEl || !lang || lang.indexOf('en') === 0 || i18nBusy) return;

		var cacheKey = 'cbx-i18n-' + lang;
		var cache = jsonGet(cacheKey, {});
		var todo = [], nodes = [], node;
		[panelEl, ddEl].forEach(function (root) {
		if (!root) return;
		var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
		while ((node = walker.nextNode())) {
			var raw = node.nodeValue;
			var t = raw.trim();
			if (!t || t.length < 2 || /^[\sו●★✎▶+−–—]+$/.test(t)) continue;
			if (node._cbxSrc === t) continue;
			nodes.push({ node: node, src: t, pad: raw });
			if (!cache[t] && todo.indexOf(t) === -1) todo.push(t);
		}
		});

		function paint() {
			nodes.forEach(function (n) {
				var out = cache[n.src];
				if (!out) return;
				n.node.nodeValue = n.pad.replace(n.src, out);
				n.node._cbxSrc = out;
			});
		}

		if (!todo.length) { paint(); return; }

		i18nBusy = true;
		var i = 0;
		function next() {
			if (i >= todo.length) {
				jsonSet(cacheKey, cache);
				i18nBusy = false;
				paint();
				return;
			}
			var src = todo[i++];
			translate(src, lang).then(function (r) {
				if (r && r.text) cache[src] = r.text;
			}).catch(function () {}).then(function () { setTimeout(next, 160); });
		}
		paint();
		next();
	}

	/* ================================================================== *
	 * random rooms by gender
	 *
	 * The room-list endpoint is tried first; if its shape is not what we
	 * expect, the public gender page is parsed for room links instead, so
	 * this keeps working when the API moves.
	 * ================================================================== */

	var GENDER_PAGES = { f: 'female-cams', m: 'male-cams', c: 'couple-cams', t: 'trans-cams' };

	function harvestUsernames(obj, out) {
		if (!obj || typeof obj !== 'object' || out.length > 300) return out;
		if (Array.isArray(obj)) { obj.forEach(function (o) { harvestUsernames(o, out); }); return out; }
		var u = obj.username || obj.room || obj.slug;
		if (typeof u === 'string' && /^[a-z0-9_]{3,}$/i.test(u) && out.indexOf(u.toLowerCase()) === -1) {
			out.push(u.toLowerCase());
		}
		Object.keys(obj).forEach(function (k) { harvestUsernames(obj[k], out); });
		return out;
	}

	function roomsForGender(g) {
		var page = GENDER_PAGES[g] || 'female-cams';
		return fetch('/api/ts/roomlist/room-list/?genders=' + g + '&limit=90', { credentials: 'include' })
			.then(function (r) { if (!r.ok) throw new Error('bad status'); return r.json(); })
			.then(function (d) {
				var names = harvestUsernames(d, []);
				if (!names.length) throw new Error('no usernames');
				return names;
			})
			.catch(function () {
				return fetch('/' + page + '/', { credentials: 'include' })
					.then(function (r) { return r.text(); })
					.then(function (html) {
						var out = [], m, re = /href="\/([a-z0-9_]{3,})\/"/g;
						while ((m = re.exec(html))) {
							var u = m[1];
							if (isRoomPath(u) && out.indexOf(u) === -1) out.push(u);
						}
						return out;
					});
			});
	}

	function fillRandom(target) {
		var genders = (S.randomGenders || 'f').split('');
		var want = S.randomCount || 6;
		toast('Looking for rooms…');

		Promise.all(genders.map(function (g) {
			return roomsForGender(g).catch(function () { return []; });
		})).then(function (lists) {
			var pool = [];
			lists.forEach(function (l) { pool = pool.concat(l); });
			var have = jsonGet(MULTI_KEY, []);
			pool = pool.filter(function (u) { return have.indexOf(u) === -1; });
			if (!pool.length) { toast('Found nothing to add'); return; }

			for (var i = pool.length - 1; i > 0; i--) {
				var j = Math.floor(Math.random() * (i + 1));
				var t = pool[i]; pool[i] = pool[j]; pool[j] = t;
			}
			var picked = pool.slice(0, want);
			if (target) picked.forEach(target);
			else {
				jsonSet(MULTI_KEY, have.concat(picked));
				toast('Added ' + picked.length + ' rooms — open the multi cam tab');
			}
		}).catch(function (e) {
			log('random', e);
			toast('Could not fetch the room list');
		});
	}

	/* ================================================================== *
	 * toolbar under the player
	 *
	 * One row beneath the player on room pages: a zoom slider and four
	 * menus — Picture, Sound, Record, Room. Each menu carries the live
	 * controls and the settings that only make sense there, so the drawer
	 * keeps site-wide preferences only. Menus are fixed-positioned and
	 * live on body, so the page's overflow rules cannot clip them.
	 * ================================================================== */

	var ddEl = null, ddOpen = null, ddAnchor = null, ddCtx = null;

	var STRIP_CSS = [
		'#cbx-dd{position:fixed;z-index:2147483550;width:320px;max-width:calc(100vw - 16px);max-height:70vh;overflow-y:auto;box-sizing:border-box;',
		'background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:12px;padding:10px 14px 12px;',
		'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.5);display:none}',
		'#cbx-dd.cbx-on{display:block}',
		'#cbx-dd h3{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:12px 0 2px}#cbx-dd h3:first-child{margin-top:0}',
		'#cbx-dd .cbx-range{padding:4px 0}#cbx-dd .cbx-range label{width:78px;font-size:12px;color:var(--cbx-dim)}',
		'#cbx-dd .cbx-chips{margin-top:4px}',
		'#cbx-dd .cbx-b-wide{margin-top:6px}',
		'#cbx-dd .cbx-row{padding:6px 0}',
		'#cbx-dd .cbx-act{display:block;width:100%;text-align:left;appearance:none;font:inherit;font-size:13px;padding:9px 10px;margin:0 -10px;width:calc(100% + 20px);',
		'box-sizing:border-box;border:0;border-radius:8px;background:transparent;color:var(--cbx-fg);cursor:pointer}',
		'#cbx-dd .cbx-act:hover,#cbx-dd .cbx-act:focus-visible{background:var(--cbx-bg-2);outline:0}',
		'#cbx-dd .cbx-act small{display:block;color:var(--cbx-dim);font-size:11px;margin-top:2px;white-space:pre-wrap}',
		'#cbx-dd .cbx-mrow{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 0 8px;font-size:13px}#cbx-dd .cbx-mrow .cbx-chips{margin:0}',
		'#cbx-dd .cbx-nav{display:flex;align-items:center;gap:8px}#cbx-dd .cbx-nav small{display:inline;margin:0 0 0 auto;padding-right:2px}#cbx-dd .cbx-nav::after{content:"\\203A";color:var(--cbx-dim);font-size:16px;line-height:1}',
		'#cbx-dd .cbx-back{color:var(--cbx-dim);font-size:12px;padding-top:4px;padding-bottom:4px;margin-bottom:2px}',
		'#cbx-dd .cbx-pick.cbx-on{background:var(--cbx-bg-2);box-shadow:inset 3px 0 0 var(--cbx-accent)}',
		'#cbx-dd.cbx-under{max-width:none;border-radius:0 0 12px 12px;border-top-width:0;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}',
		'#cbx-dd.cbx-drawer{max-width:none;border-radius:12px 0 0 12px;border-width:0 0 0 1px;background:rgba(20,23,26,.92);-webkit-overflow-scrolling:touch;overscroll-behavior:contain;',
		'padding:max(10px,env(safe-area-inset-top,0px)) max(14px,env(safe-area-inset-right,0px)) max(12px,env(safe-area-inset-bottom,0px)) 14px}',
		'html.cbx-touch #cbx-dd .cbx-act{padding-top:12px;padding-bottom:12px;min-height:44px}html.cbx-touch #cbx-dd .cbx-row{min-height:44px}html.cbx-touch #cbx-dd .cbx-chip{min-height:36px}',
		'@media (prefers-reduced-motion:reduce){#cbx-rec.cbx-rec-on #cbx-rec-dot{animation:none}}'
	].join('');


	function rangeHTML(id, label, min, max, step, val, unit) {
		return '<div class="cbx-range"><label for="' + id + '">' + label + '</label><input type="range" id="' + id + '" min="' + min + '" max="' + max + '" step="' + step + '" value="' + val + '"><b id="' + id + '-val">' + val + unit + '</b></div>';
	}

	function chipHTML(id, label, on) {
		return '<button class="cbx-chip' + (on ? ' cbx-on' : '') + '" data-chip="' + id + '">' + label + '</button>';
	}

	// what the quality page lists: our engine's tracks, or the site's menu (read once, cached)
	function qualityOptions() {
		var v = activeVideo(); if (!v) return [];
		if (v === P.video && P.eng) {
			var cur = P.eng.active(), auto = P.eng.isAuto();
			return [{ value: 'auto', label: 'Auto', on: auto }].concat(P.eng.tracks().map(function (t) { return { value: 't' + t.id, label: t.label, on: !auto && !!cur && cur.id === t.id }; }));
		}
		var h = v._cbxHls;
		if (h && h.levels && h.levels.length) {
			var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel;
			return [{ value: 'auto', label: 'Auto', on: h.autoLevelEnabled }].concat(hlsLevelOptions(h).map(function (o) { return { value: o.value, label: o.label, on: !h.autoLevelEnabled && o.i === lvl }; }));
		}
		return siteQualityCache.opts.map(function (o) { return { value: o.value, label: o.label, on: !!v.videoHeight && o.height === v.videoHeight }; });
	}
	function speedChips() {
		var v = activeVideo(), r = v ? v.playbackRate : 1;
		return '<div class="cbx-chips cbx-speed">' + [[0.5, '½×'], [1, '1×'], [2, '2×']].map(function (s) { return '<button class="cbx-chip' + (Math.abs(r - s[0]) < 0.01 ? ' cbx-on' : '') + '" data-speed="' + s[0] + '">' + s[1] + '</button>'; }).join('') + '</div>';
	}
	function ddHTML(id) {
		var user = roomName();
		var back = ddCtx && ddCtx.gear ? '<button class="cbx-act cbx-back" data-page="menu">‹ Back</button>' : '';
		if (id === 'menu') {
			var q = qualityOptions().filter(function (o) { return o.on; })[0], v0 = activeVideo();
			var qNow = q ? q.label : (v0 && v0.videoHeight ? v0.videoHeight + 'p' : '…');
			return '<div class="cbx-mrow"><span>Speed</span>' + speedChips() + '</div>' +
				'<button class="cbx-act cbx-nav" data-page="quality">Quality<small>' + esc(qNow) + '</small></button>' +
				'<button class="cbx-act cbx-nav" data-page="pic">Picture' + (pictureTouched() ? '<small>adjusted</small>' : '') + '</button>' +
				'<button class="cbx-act cbx-nav" data-page="snd">Sound' + (soundShaped() ? '<small>' + (S.normalize ? (S.volumeBoost !== 100 || S.voiceBoost ? 'levelled + boosted' : 'levelled') : 'boosted') + '</small>' : '') + '</button>' +
				'<button class="cbx-act cbx-nav" data-page="rec">Record' + (recording() ? '<small>recording</small>' : '') + '</button>' +
				'<button class="cbx-act cbx-nav" data-page="tools">Tools</button>' +
				'<button class="cbx-act cbx-nav" data-page="room">Room</button>' +
				'<button class="cbx-act" data-do="panel">All settings…</button>';
		}
		if (id === 'quality') {
			var opts = qualityOptions();
			if (!opts.length) return back + '<h3>Quality</h3><p class="cbx-note" id="cbx-q-wait">Reading the site\'s quality menu…</p>';
			return back + '<h3>Quality</h3>' + opts.map(function (o) { return '<button class="cbx-act cbx-pick' + (o.on ? ' cbx-on' : '') + '" data-q="' + esc(o.value) + '">' + esc(o.label) + '</button>'; }).join('');
		}
		if (id === 'pic') return back + '<h3>Picture</h3>' +
			rangeHTML('cbx-vf-bright', 'Brightness', 20, 250, 5, vf.bright, '%') +
			rangeHTML('cbx-vf-contrast', 'Contrast', 20, 250, 5, vf.contrast, '%') +
			rangeHTML('cbx-vf-sat', 'Saturation', 0, 250, 5, vf.sat, '%') +
			rangeHTML('cbx-zoom-dd', 'Zoom', 50, 300, 5, Math.round(effZoom() * 100), '%') +
			'<div class="cbx-chips">' + chipHTML('rot', 'Rotate', xform.rot !== 0) + chipHTML('flip', 'Flip', xform.flip === -1) + chipHTML('invert', 'Invert', vf.invert) + '</div>' +
			'<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-pic-reset">Reset picture</button>';

		if (id === 'snd') return back + '<h3>Cam audio</h3>' +
			rangeHTML('cbx-boost', 'Boost', 100, 400, 10, S.volumeBoost, '%') +
			rowHTML('voiceBoost', 'Voice boost', 'Trims rumble and lifts speech.') +
			rowHTML('normalize', 'Even out the volume', 'Lifts quiet voices, holds tip sounds and music down. On a phone the sound may wait for a tap after you switch tabs.') +
			'<div class="cbx-note" id="cbx-boost-note"></div>' +
			'<h3>Tip sounds</h3>' +
			rangeHTML('cbx-tipvol', 'Tip volume', 0, 100, 5, S.tipVolume, '%') +
			'<h3>Other tabs</h3>' +
			rowHTML('bgMute', 'Mute when this tab is hidden', null) +
			rowHTML('exclusiveAudio', 'Only one tab plays sound', 'Unmuting here mutes the other CB tabs.');

		if (id === 'tools') return back + '<h3>Tools</h3>' +
			'<button class="cbx-act" data-do="fs">' + (playerFills() ? 'Leave fullscreen' : 'Fullscreen') + '<small>or double-tap the middle of the picture</small></button>' +
			(!canElementFs() && nativeFsAvailable() ? '<button class="cbx-act" data-do="nativefs">Native fullscreen<small>Apple\'s own player: hides the browser too, but has no rewind bar</small></button>' : '') +
			'<button class="cbx-act" data-do="snap">Save a frame<small>S</small></button>' +
			(S.pipButton ? '<button class="cbx-act" data-do="pip">Picture in picture</button>' : '') +
			(S.clipSave ? '<button class="cbx-act" data-do="clip">Save the buffer<small>D · the last minutes at source quality</small></button>' : '') +
			'<button class="cbx-act" data-do="loop">' + (P.loopB != null ? 'Clear loop' : P.loopA != null ? 'Set loop end' : 'Set loop start') + '<small>A</small></button>' +
			'<button class="cbx-act" data-do="mark">Bookmark this moment<small>B · [ ] to jump</small></button>' +
			(P.marks && P.marks.length ? '<button class="cbx-act" data-do="bmprev">Previous bookmark</button><button class="cbx-act" data-do="bmnext">Next bookmark</button>' : '') +
			'<button class="cbx-act" data-do="stats">' + (S.statsOverlay ? 'Hide stats' : 'Show stats') + '<small>I</small></button>';

		if (id === 'rec') return back + '<h3>Recording</h3>' +
			'<button class="cbx-act" data-do="rec">' + (recording() ? 'Stop and save' : 'Start recording') + '<small>R</small></button>' +
			rowHTML('recAudioOnly', 'Audio only', null) +
			rowHTML('recVideoOnly', 'Video only', null) +
			rowHTML('recLowPerf', 'Low performance mode', 'Lower bitrate; easier on slow machines.') +
			rowHTML('recWarnLeave', 'Warn before leaving while recording', null) +
			'<small class="cbx-note">Record re-encodes what plays from the moment you press it, forward. It costs CPU and battery, and on Firefox for Android audio only comes through with the Sound menu active.</small>' +
			'<h3>Instant clip</h3>' +
			rowHTML('clipSave', 'Instant clip of the rewind window', 'Keeps the original stream segments already downloaded for rewind, so "Save the buffer" writes the last minutes in a second at source quality, no re-encoding. Set an A-B loop to save just that range. Uses extra memory.') +
			'<button class="cbx-b cbx-b-wide" id="cbx-dd-clip">Save the buffer now</button>';

		if (id === 'card') {
			var cu = ddCtx.user, cnote = jsonGet(NOTES_KEY, {})[cu] || '';
			return '<button class="cbx-act" data-room="hide">Hide this cam</button>' +
				(ddCtx.cc ? '<button class="cbx-act" data-room="country">Hide ' + esc(countryName(ddCtx.cc)) + '</button>' : '') +
				'<button class="cbx-act" data-room="note">' + (cnote ? 'Edit note' : 'Add a note') + (cnote ? '<small>' + esc(cnote) + '</small>' : '') + '</button>' +
				'<button class="cbx-act" data-room="alert">' + (alertList().indexOf(cu) !== -1 ? 'Stop alerting when live' : 'Alert me when live') + '</button>' +
				(S.multiCam ? '<button class="cbx-act" data-room="multi">Add to multi cam</button>' : '');
		}

		if (id === 'room') {
			var note = jsonGet(NOTES_KEY, {})[user] || '';
			var alerting = alertList().indexOf(user) !== -1;
			return back + '<h3>Room</h3><button class="cbx-act" data-room="watch">Watch without chat</button>' +
				'<button class="cbx-act" data-room="url">Copy stream URL</button>' +
				'<button class="cbx-act" data-room="alert">' + (alerting ? 'Stop alerting when live' : 'Alert me when live') + '</button>' +
				'<button class="cbx-act" data-room="note">' + (note ? 'Edit note' : 'Add a note') + (note ? '<small>' + esc(note) + '</small>' : '') + '</button>' +
				(S.multiCam ? '<button class="cbx-act" data-room="multi">Add to multi cam</button>' : '') +
				'<button class="cbx-act" data-room="hide">Hide this room</button>';
		}
		return '';
	}

	// the toolbar and the dropdown menus share one stylesheet; the card menu
	// needs it on list pages where no toolbar is ever built
	function ensureStripCSS() {
		if (!$('#cbx-strip-css')) (document.head || document.documentElement).appendChild(el('style', { id: 'cbx-strip-css' }, STRIP_CSS));
	}
	function openDD(id, anchor, ctx) {
		if (!ddEl) {
			ensureStripCSS();
			ddEl = el('div', { id: 'cbx-dd', role: 'menu' });
			uiHost().appendChild(ddEl);
			bindDD();
		}
		if (ddOpen === id && ddAnchor === anchor) { closeDD(); return; }
		ddOpen = id; ddAnchor = anchor; ddCtx = ctx || null;
		ddEl._openedAt = Date.now();
		ddEl.innerHTML = ddHTML(id);
		ddEl.classList.add('cbx-on');
		qualityRead(id);
		if (P.bar) $$('button[data-act="gear"]', P.bar).forEach(function (b) { b.classList.toggle('cbx-on', !!(ctx && ctx.gear)); });
		placeDD(anchor);
		syncBar();
		localizePanel();
	}
	// the Quality page reads the site's menu once per opening; the answer repaints the page, never re-asks
	function qualityRead(id) {
		if (id !== 'quality' || qualityOptions().length || ddEl._qReading) return;
		ddEl._qReading = true;
		setTimeout(function () {
			readSiteQualities(function (opts) {
				ddEl._qReading = false;
				if (ddOpen !== 'quality') return;
				ddEl.innerHTML = opts.length ? ddHTML('quality') : (ddCtx && ddCtx.gear ? '<button class="cbx-act cbx-back" data-page="menu">‹ Back</button>' : '') + '<h3>Quality</h3><p class="cbx-note">This player has no quality menu to read.</p>';
				placeDD(); localizePanel();
			});
		}, 0);
	}
	// a page change inside the open menu
	function showDD(id) {
		if (!ddEl || !ddOpen) return;
		ddOpen = id;
		ddEl.innerHTML = ddHTML(id);
		qualityRead(id);
		placeDD();
		syncBar();
		localizePanel();
	}

	function placeDD(anchor) {
		if (!ddEl || !ddOpen) return;
		anchor = anchor || ddAnchor;
		if (!anchor || !anchor.isConnected) { closeDD(); return; }
		/* Phones used to get a sheet glued to the bottom of the window: far from the gear that opened it,
		 * over the chat in portrait, over most of the picture in landscape, and outside the screen
		 * altogether when the page was zoomed. Now the menu belongs to what opened it. From the bar it
		 * hangs under the player in portrait (the picture stays visible while its sliders are dragged)
		 * and becomes a see-through drawer inside the player when the player fills the screen or there
		 * is no room below. From a room card it is a small popover by the button. Everything is clamped
		 * to the visible part of the page. */
		var st = ddEl.style, vb = viewportBox();
		ddEl.classList.remove('cbx-under', 'cbx-drawer');
		st.width = ''; st.height = ''; st.maxHeight = '';
		if (isTouch()) {
			var fromGear = !!(ddCtx && ddCtx.gear) && !!P.box && P.box.isConnected;
			if (fromGear) {
				var br = visibleRect(P.box, false), below = vb.y + vb.h - br.bottom - 8;
				if (playerFills() || below < 210) {
					var dw = Math.round(Math.min(320, Math.max(220, br.width * 0.6)));
					ddEl.classList.add('cbx-drawer');
					st.width = dw + 'px'; st.height = Math.round(br.height) + 'px'; st.maxHeight = 'none';
					st.left = Math.round(br.right - dw) + 'px'; st.top = Math.round(br.top) + 'px';
				} else {
					var ul = Math.max(br.left, vb.x), uw = Math.min(br.right, vb.x + vb.w) - ul;
					ddEl.classList.add('cbx-under');
					st.left = Math.round(ul) + 'px'; st.top = Math.round(br.bottom) + 'px';
					st.width = Math.round(uw) + 'px'; st.maxHeight = Math.round(below) + 'px';
				}
				return;
			}
			var ar = anchor.getBoundingClientRect(), pw = Math.min(ddEl.offsetWidth || 320, vb.w - 16), ph = ddEl.offsetHeight || 200;
			var pl = Math.min(Math.max(vb.x + 8, ar.right - pw), vb.x + vb.w - pw - 8), pt = ar.bottom + 6;
			if (pt + ph > vb.y + vb.h - 8) pt = ar.top - ph - 6;
			if (pt < vb.y + 8) { pt = vb.y + 8; st.maxHeight = Math.round(vb.h - 16) + 'px'; }
			st.left = Math.round(pl) + 'px'; st.top = Math.round(pt) + 'px';
			return;
		}
		var r = anchor.getBoundingClientRect(), w = ddEl.offsetWidth || 320, h = ddEl.offsetHeight || 200;
		var left = Math.min(Math.max(8, r.right - w), innerWidth - w - 8);
		// from the bar the menu opens upward over the picture; elsewhere it drops down
		var fromBar = !!anchor.closest('#cbx-bar'), top = fromBar ? r.top - h - 8 : r.bottom + 6;
		if (fromBar && top < 8) top = r.bottom + 6;
		if (!fromBar && top + h > innerHeight - 8 && r.top - h - 6 > 8) top = r.top - h - 6;
		ddEl.style.left = Math.round(left) + 'px';
		ddEl.style.top = Math.round(top) + 'px';
	}

	function closeDD() {
		if (!ddEl) return;
		ddOpen = null; ddAnchor = null; ddCtx = null; ddEl._qReading = false;
		ddEl.classList.remove('cbx-on');
		armIdleHide();
		if (P.bar) $$('button[data-act="gear"]', P.bar).forEach(function (b) { b.classList.remove('cbx-on'); });
	}

	function bindDD() {
		document.addEventListener('pointerdown', function (e) {
			if (!ddOpen) return;
			if (ddEl.contains(e.target) || (ddAnchor && ddAnchor.contains(e.target))) return;
			closeDD();
		}, true);
		document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && ddOpen) { closeDD(); e.stopPropagation(); } }, true);
		window.addEventListener('resize', function () { placeDD(); });
		var ddScrollY = 0;
		window.addEventListener('scroll', function () {
			if (!ddOpen) return;
			// phones nudge the page by a few pixels on tap (address bar, focus); only a real scroll closes the menu
			if (Date.now() - (ddEl._openedAt || 0) < 500) { ddScrollY = scrollY; return; }
			if (Math.abs(scrollY - ddScrollY) > 40) closeDD(); 
		}, { passive: true });
		ddEl.addEventListener('input', function (e) {
			var t = e.target, id = t.id, v = parseFloat(t.value), lbl = $('#' + id + '-val', ddEl);
			if (id === 'cbx-vf-bright') { vf.bright = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); }
			else if (id === 'cbx-vf-contrast') { vf.contrast = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); }
			else if (id === 'cbx-vf-sat') { vf.sat = v; if (lbl) lbl.textContent = v + '%'; applyTransform(); }
			else if (id === 'cbx-zoom-dd') { setEffZoom(v / 100); if (lbl) lbl.textContent = v + '%'; }
			else if (id === 'cbx-boost') { S.volumeBoost = Math.round(v); if (lbl) lbl.textContent = S.volumeBoost + '%'; applyAudioChain(); soundNotes(); }
			else if (id === 'cbx-tipvol') { if (lbl) lbl.textContent = t.value + '%'; }
		});
		ddEl.addEventListener('change', function (e) {
			var t = e.target;
			if (t.id === 'cbx-boost') { save(); return; }
			if (t.id === 'cbx-tipvol') { S.tipVolume = parseInt(t.value, 10) || 0; save(); tipDone = false; applyTipMute(0); return; }
			var k = t.getAttribute && t.getAttribute('data-cbx');
			if (!k) return;
			S[k] = !!t.checked;
			if (k === 'recAudioOnly' && S[k]) { S.recVideoOnly = false; }
			if (k === 'recVideoOnly' && S[k]) { S.recAudioOnly = false; }
			save();
			if (k === 'voiceBoost' || k === 'normalize') { applyAudioChain(); soundNotes(); if (k === 'normalize') toast(S.normalize ? 'Volume evened out' : 'Volume as broadcast'); }
			onSettingChanged(k);
			$$('input[data-cbx]', ddEl).forEach(function (i) { i.checked = !!S[i.getAttribute('data-cbx')]; });
		});
		ddEl.addEventListener('dblclick', function (e) {
			var t = e.target;
			if (!(t instanceof HTMLInputElement) || t.type !== 'range') return;
			var def = { 'cbx-vf-bright': 100, 'cbx-vf-contrast': 100, 'cbx-vf-sat': 100, 'cbx-zoom-dd': 100, 'cbx-boost': 100 }[t.id];
			if (def == null) return;
			t.value = def;
			t.dispatchEvent(new Event('input', { bubbles: true }));
			t.dispatchEvent(new Event('change', { bubbles: true }));
		});
		ddEl.addEventListener('click', function (e) {
			var nav = e.target.closest('[data-page]');
			if (nav) { e.preventDefault(); e.stopPropagation(); showDD(nav.getAttribute('data-page')); return; }
			var sp = e.target.closest('[data-speed]');
			if (sp) { var vv = activeVideo(); if (vv) setRate(vv, parseFloat(sp.getAttribute('data-speed'))); $$('[data-speed]', ddEl).forEach(function (b) { b.classList.toggle('cbx-on', b === sp); }); return; }
			var pick = e.target.closest('[data-q]');
			if (pick) { pickQuality(pick.getAttribute('data-q')); setTimeout(function () { if (ddOpen === 'quality') showDD('quality'); }, 700); return; }
			var doBtn = e.target.closest('[data-do]');
			if (doBtn) {
				var doWhat = doBtn.getAttribute('data-do');
				e.preventDefault(); e.stopPropagation();
				closeDD();
				if (doWhat === 'bmprev') jumpBookmark(-1); else if (doWhat === 'bmnext') jumpBookmark(1); else runAct(doWhat);
				return;
			}
			var chip = e.target.closest('.cbx-chip');
			if (chip && chip.hasAttribute('data-chip')) {
				var c = chip.getAttribute('data-chip');
				if (c === 'rot') xform.rot = (xform.rot + 90) % 360;
				else if (c === 'flip') xform.flip *= -1;
				else if (c === 'invert') vf.invert = !vf.invert;
				applyTransform();
				chip.classList.toggle('cbx-on', c === 'rot' ? xform.rot !== 0 : c === 'flip' ? xform.flip === -1 : !!vf[c]);
				return;
			}
			if (e.target.id === 'cbx-pic-reset') { resetTransform(); ddEl.innerHTML = ddHTML('pic'); return; }
			if (e.target.id === 'cbx-dd-clip') { closeDD(); saveClip(); return; }
			var act = e.target.closest('.cbx-act');
			if (!act) return;
			var what = act.getAttribute('data-room'), fromCard = ddOpen === 'card', ctx = ddCtx || {};
			closeDD();
			if (fromCard) roomAction(what, ctx.user, ctx);
			else roomAction(what, roomName(), { confirm: true });
		});
	}

	function soundNoteText() {
		if (!soundShaped()) return '';
		var v = activeVideo(), ch = v && chains.get(v);
		if (ch) return S.normalize && !ch.comp ? 'This browser has no compressor: the volume cannot be evened out here.' : '';
		return v ? 'Not available for this player: the stream is not routed through the page.' : (roomName() ? 'Starts when the video plays.' : 'Applies in a room, when the video plays.');
	}
	function soundNotes() {
		var txt = soundNoteText(), a = ddEl && $('#cbx-boost-note', ddEl), b = panelEl && $('#cbx-p-boost-note', panelEl);
		if (a && a.textContent !== txt) a.textContent = txt;
		if (b && b.textContent !== txt) b.textContent = txt;
	}

	// bar state that is not the timeline: the record button and badge, the zoom slider, the gear dot
	function syncBar() {
		var bar = P.bar, on = recording(), elapsed = on ? fmtTime((Date.now() - rec.since) / 1000) : '';
		if (bar && bar.isConnected) {
			var rb = $('#cbx-rec', bar), rt = $('#cbx-rec-txt', bar);
			if (rb) { rb.classList.toggle('cbx-rec-on', on); rb.setAttribute('aria-pressed', on ? 'true' : 'false'); }
			if (rt && rt.textContent !== elapsed) rt.textContent = elapsed;
			var g = $('button[data-act="gear"]', bar);
			if (g) g.classList.toggle('cbx-dot', pictureTouched() || soundShaped());
		}
		var badge = P.block && $('#cbx-recbadge', P.block);
		if (badge) { badge.hidden = !on; var bs = badge.lastChild; if (bs && bs.textContent !== elapsed) bs.textContent = elapsed; }
		var z = P.block && $('#cbx-zoom', P.block);
		if (z && document.activeElement !== z) { var zv = Math.round(effZoom() * 100); if (String(z.value) !== String(zv)) z.value = zv; }
		zoomKnob();
		var zl = P.block && $('#cbx-zoom-val', P.block); if (zl) { var zt = zoomLabel(); if (zl.textContent !== zt) zl.textContent = zt; }
		// a focused control that pokes past the box once made the site's container scroll sideways; never let that stick
		if (P.box && (P.box.scrollLeft || P.box.scrollTop)) { P.box.scrollLeft = 0; P.box.scrollTop = 0; }
		if (ddOpen) { soundNotes(); placeDD(); }
	}

	/* ================================================================== *
	 * panel
	 * ================================================================== */

	var UI_CSS = [
		':root{--cbx-bg:#14171a;--cbx-bg-2:#1c2126;--cbx-line:#2a3138;--cbx-fg:#e8ebed;--cbx-dim:#8b969e;--cbx-accent:#f67300}',
		'#cbx-launcher{position:fixed;z-index:2147483400;width:40px;height:40px;border-radius:20px;border:1px solid var(--cbx-line);',
		'background:var(--cbx-bg);color:var(--cbx-fg);display:flex;align-items:center;justify-content:center;cursor:grab;',
		'box-shadow:0 2px 10px rgba(0,0,0,.35);opacity:.72;transition:opacity .15s ease;touch-action:none;-webkit-tap-highlight-color:transparent}',
		'#cbx-launcher:hover,#cbx-launcher:focus-visible{opacity:1}',
		'#cbx-dock,#cbx-multidock button,#cbx-panel button,#cbx-dd button{touch-action:manipulation}',
		/* no transition on the size: the pill and the two round buttons are placed from the gear's box the instant the drawer opens, and a box that is still growing put them on top of it the first time */
		'html.cbx-drawer #cbx-launcher{left:auto!important;right:0!important;width:34px;height:72px;border-radius:16px 0 0 16px;border-right-width:0;opacity:.5;box-shadow:none;transition:opacity .15s ease}',
		'html.cbx-drawer #cbx-launcher svg{width:20px;height:20px}',
		'html.cbx-drawer:not(.cbx-drawer-open) :is(#cbx-dock,#cbx-multidock){display:none!important}',
		'html.cbx-drawer.cbx-drawer-open #cbx-launcher{right:6px!important;width:40px;height:40px;margin-top:16px;border-radius:20px;border-right-width:1px;opacity:1;box-shadow:0 2px 10px rgba(0,0,0,.35)}html.cbx-drawer.cbx-drawer-open #cbx-launcher svg{width:18px;height:18px}',
		'html.cbx-drawer.cbx-drawer-open :is(#cbx-dock,#cbx-multidock){opacity:1}',
		'#cbx-dock{position:fixed;z-index:2147483400;display:none;align-items:center;justify-content:center;gap:7px;height:40px;min-width:112px;padding:0 14px 0 12px;border-radius:20px;box-sizing:border-box;',
		'border:1px solid var(--cbx-line);background:var(--cbx-bg);color:var(--cbx-fg);font:500 13px/1 system-ui,sans-serif;cursor:pointer;opacity:.72;',
		'box-shadow:0 2px 10px rgba(0,0,0,.35);white-space:nowrap;-webkit-tap-highlight-color:transparent;transition:opacity .15s ease}',
		'#cbx-dock:hover,#cbx-dock:focus-visible,#cbx-multidock:hover,#cbx-multidock:focus-visible{opacity:1}',
		'#cbx-multidock{position:fixed;z-index:2147483400;display:none;align-items:center;gap:6px;opacity:.72;transition:opacity .15s ease}',
		'#cbx-multidock button{position:relative;display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:20px;padding:0;border:1px solid var(--cbx-line);background:var(--cbx-bg);color:var(--cbx-fg);cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.35);-webkit-tap-highlight-color:transparent}',
		'#cbx-multidock svg{width:18px;height:18px}#cbx-multidock .cbx-in{color:#3ad07a;border-color:rgba(58,208,122,.5)}',
		'#cbx-multidock b{display:none;position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;padding:0 4px;border-radius:8px;background:#f67300;color:#fff;font:600 10px/16px system-ui,sans-serif;text-align:center;box-shadow:0 0 0 2px var(--cbx-bg)}#cbx-multidock .cbx-has b{display:block}',
		'.cbx-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none;background:#8b969e}',
		'#cbx-dock .cbx-dot{width:16px;height:16px;border-radius:0;background:none;display:inline-flex}#cbx-dock .cbx-dot svg{width:16px;height:16px}.cbx-dot-on{color:#3ad07a}.cbx-dot-off{color:#8b969e}.cbx-dot-nat{color:#f6a25e}',
		'#cbx-launcher svg{width:18px;height:18px}#cbx-launcher svg path{fill:currentColor;stroke:none}',
		'#cbx-scrim{position:fixed;inset:0;z-index:2147483500;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;transition:opacity .18s ease}',
		'#cbx-scrim.cbx-on{opacity:1;pointer-events:auto}',
		'#cbx-panel{position:fixed;z-index:2147483600;background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);',
		'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;',
		'box-shadow:0 10px 40px rgba(0,0,0,.5);transition:transform .22s cubic-bezier(.2,.8,.3,1)}',
		'#cbx-panel h1{font-size:15px;font-weight:600;margin:0}',
		'#cbx-panel h2{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:0 0 2px}',
		'@media (min-width:700px){#cbx-panel{top:0;right:0;bottom:0;width:340px;border-width:0 0 0 1px;transform:translateX(102%)}',
		'#cbx-panel.cbx-on{transform:translateX(0)}}',
		'@media (max-width:699.98px){#cbx-panel{left:0;right:0;bottom:0;height:min(80vh,640px);border-radius:16px 16px 0 0;border-width:1px 0 0;transform:translateY(102%)}',
		'#cbx-panel.cbx-on{transform:translateY(0)}#cbx-grip{display:block}}',
		'#cbx-grip{display:none;width:36px;height:4px;border-radius:2px;background:var(--cbx-line);margin:8px auto 0;flex:none}',
		'#cbx-head{display:flex!important;position:static!important;width:auto!important;height:auto!important;align-items:center;justify-content:space-between;padding:12px 16px 8px;flex:none;box-sizing:border-box;margin:0}',
		'#cbx-head h1{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
		'#cbx-tabs{display:flex!important;visibility:visible!important;gap:2px;overflow-x:auto;scrollbar-width:none;padding:0 10px 8px;flex:none;min-height:36px;border-bottom:1px solid var(--cbx-line)}',
		'#cbx-tabs::-webkit-scrollbar{display:none}',
		'.cbx-tab{flex:none;display:inline-block!important;appearance:none;border:0;background:transparent;color:var(--cbx-dim);font:inherit;font-size:13px;',
		'padding:8px 12px;border-radius:8px;cursor:pointer;white-space:nowrap;-webkit-tap-highlight-color:transparent}',
		'.cbx-tab:hover{color:var(--cbx-fg)}',
		'.cbx-tab.cbx-on{background:var(--cbx-bg-2);color:var(--cbx-fg);box-shadow:inset 0 -2px 0 var(--cbx-accent)}',
		'#cbx-filter-wrap{padding:8px 16px 0;flex:none}',
		'#cbx-filter{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:7px 10px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg)}',
		'#cbx-panel.cbx-filtering #cbx-tabs,#cbx-panel.cbx-filtering #cbx-secpick-wrap{display:none!important}',
		'#cbx-panel.cbx-filtering .cbx-pane{display:none!important}#cbx-panel.cbx-filtering .cbx-pane.cbx-match{display:block!important}',
		'#cbx-panel.cbx-filtering .cbx-pane::before{content:attr(data-pane);display:block;font-size:11px;text-transform:capitalize;color:var(--cbx-dim);margin:0 0 4px}',
		'#cbx-panel .cbx-hide{display:none!important}',
		'#cbx-body .cbx-pane{display:none}',
		'#cbx-body .cbx-pane.cbx-on{display:block}',
		'#cbx-panel h3{font-size:12px;font-weight:600;color:var(--cbx-dim);margin:14px 0 2px}',
		'.cbx-tab-reset{margin-top:16px;opacity:.75}',
		'#cbx-panel h3:first-child{margin-top:2px}',
		'.cbx-sched{display:flex;flex-direction:column;gap:2px;margin-top:6px}',
		'.cbx-sched-row{display:flex;align-items:center;gap:2px}',
		'.cbx-sched-row b{width:30px;font:11px/1 system-ui,sans-serif;font-weight:400;color:var(--cbx-dim)}',
		'.cbx-sched-row i{flex:1;height:11px;border-radius:2px;background:var(--cbx-bg-2)}',
		'.cbx-sched-row i.cbx-on{background:var(--cbx-accent)}',
		'.cbx-sched-row i.cbx-l1{opacity:.3}.cbx-sched-row i.cbx-l2{opacity:.55}.cbx-sched-row i.cbx-l3{opacity:.8}.cbx-sched-row i.cbx-l4{opacity:1}',
		'.cbx-sched-row i.cbx-now{outline:1px solid var(--cbx-fg);outline-offset:-1px}',
		'.cbx-sched-hours i{background:none;height:auto;font:9px/1 system-ui,sans-serif;color:var(--cbx-dim);overflow:visible;white-space:nowrap}',
		'.cbx-livedot{color:#3ad07a;font-size:10px;margin-left:5px}',
		'.cbx-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}',
		'.cbx-chip{appearance:none;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-dim);',
		'font:inherit;font-size:12px;padding:7px 11px;border-radius:16px;cursor:pointer}',
		'.cbx-chip.cbx-on{background:var(--cbx-accent);border-color:var(--cbx-accent);color:#fff}',
		'#cbx-body .cbx-ta{width:100%;box-sizing:border-box;resize:vertical;background:var(--cbx-bg-2);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:8px;padding:8px;font:13px/1.4 inherit;margin:2px 0 6px}',
		'#cbx-body{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 16px 24px;flex:1}',
		'#cbx-secpick-wrap{display:none;align-items:center;gap:8px;padding:6px 0 10px;border-bottom:1px solid var(--cbx-line);margin-bottom:6px}',
		'html.cbx-touch #cbx-secpick-wrap{display:flex}#cbx-secpick-wrap label{font-size:12px;color:var(--cbx-dim)}',
		'#cbx-secpick{flex:1;font:14px system-ui,sans-serif;padding:8px 10px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg)}',
		'#cbx-body .cbx-pane{padding:12px 0;border-bottom:1px solid var(--cbx-line)}',
		'#cbx-body .cbx-pane:last-of-type{border-bottom:0}',
		'.cbx-row{display:flex;align-items:center;gap:12px;padding:7px 0;cursor:pointer}',
		'.cbx-row span{flex:1;min-width:0}',
		'.cbx-sw{flex:none;width:38px;height:22px;border-radius:11px;background:var(--cbx-bg-2);border:1px solid var(--cbx-line);position:relative;transition:background .15s ease}',
		'.cbx-sw::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--cbx-dim);transition:transform .15s ease,background .15s ease}',
		'.cbx-row input{position:absolute;opacity:0;width:0;height:0}',
		'.cbx-row input:checked + .cbx-sw{background:var(--cbx-accent);border-color:var(--cbx-accent)}',
		'.cbx-row input:checked + .cbx-sw::after{transform:translateX(16px);background:#fff}',
		'.cbx-row input:focus-visible + .cbx-sw{outline:2px solid var(--cbx-accent);outline-offset:2px}',
		'.cbx-b{appearance:none;font:inherit;font-size:13px;padding:8px 12px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg);cursor:pointer}',
		'.cbx-b:hover{border-color:var(--cbx-dim)}',
		'.cbx-b-accent{background:var(--cbx-accent);border-color:var(--cbx-accent);color:#fff}',
		'.cbx-b-wide{display:block;width:100%;margin-top:8px;text-align:center}',
		'.cbx-b-quiet{background:transparent}',
		'.cbx-b-pair{display:flex;gap:8px;margin-top:8px}.cbx-b-pair .cbx-b{flex:1;margin-top:0}',
		'#cbx-close{flex:none;width:30px;height:30px;min-width:30px;padding:0;border-radius:15px;line-height:1;font-size:18px;margin-left:8px}',
		'.cbx-note{color:var(--cbx-dim);font-size:12px;margin:6px 0 0}',
		'.cbx-mono{font:11px/1.5 ui-monospace,Menlo,Consolas,monospace;color:var(--cbx-dim);white-space:pre-wrap;margin-top:8px}',
		'.cbx-list-item{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--cbx-dim);padding:3px 0}',
		'.cbx-list-item b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:400}',
		'.cbx-range{display:flex;align-items:center;gap:10px;padding:6px 0}.cbx-range label{font-size:13px;flex:none}',
		'.cbx-range input{flex:1;min-width:0;accent-color:var(--cbx-accent)}.cbx-range b{flex:none;width:38px;text-align:right;font-size:12px;color:var(--cbx-dim)}',
		'.cbx-sel{width:100%;margin-top:6px;padding:7px 9px;border-radius:8px;border:1px solid var(--cbx-line);background:var(--cbx-bg-2);color:var(--cbx-fg);font:inherit;font-size:13px}',
		'html.cbx-picking *{cursor:crosshair!important}',
		'.cbx-pick-hl{outline:2px solid var(--cbx-accent)!important;outline-offset:-2px!important;background:rgba(246,115,0,.12)!important}',
		'#cbx-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(8px);z-index:2147483647;',
		'background:var(--cbx-bg);color:var(--cbx-fg);border:1px solid var(--cbx-line);border-radius:8px;padding:9px 14px;',
		'font:13px system-ui,-apple-system,sans-serif;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;max-width:80vw}',
		'#cbx-toast.cbx-on{opacity:1;transform:translateX(-50%) translateY(0)}',
		/* iOS zooms the whole page when an input with text under 16px gets the focus, and leaves it zoomed:
		   from then on every fixed thing of ours sat partly outside the screen */
		'html.cbx-touch :is(#cbx-filter,.cbx-sel,#cbx-secpick,#cbx-body .cbx-ta),html.cbx-touch #cbx-dd :is(select,input[type=text],textarea){font-size:16px}',
		'@supports (height:1dvh){@media (max-width:699.98px){#cbx-panel{height:min(80dvh,640px)}}}',
		'#cbx-body{padding-bottom:calc(24px + env(safe-area-inset-bottom,0px))}#cbx-toast{bottom:calc(24px + env(safe-area-inset-bottom,0px))}',
		'#cbx-diag{display:none;margin-top:8px;max-height:40vh;overflow:auto;-webkit-overflow-scrolling:touch;-webkit-user-select:text;user-select:text;font:10.5px/1.45 ui-monospace,Menlo,Consolas,monospace;color:var(--cbx-dim);white-space:pre-wrap;word-break:break-word;border:1px solid var(--cbx-line);border-radius:8px;padding:8px;background:var(--cbx-bg-2)}#cbx-diag.cbx-on{display:block}',
		'@media (prefers-reduced-motion:reduce){#cbx-panel,#cbx-scrim,#cbx-toast,.cbx-sw,.cbx-sw::after{transition:none}}'
	].join('');

	var MULTI_CSS = [
		'html.cbx-multi-page body>:not(#cbx-multi):not(#cbx-toast){display:none!important}html.cbx-multi-page,html.cbx-multi-page body{background:#0f1215!important}',
		'#cbx-multi{position:fixed;top:0;right:0;bottom:0;left:0;inset:0;z-index:2147483000;background:#0f1215;color:#e8ebed;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;box-sizing:border-box;',
		'padding:10px;padding:max(10px,env(safe-area-inset-top)) max(10px,env(safe-area-inset-right)) max(10px,env(safe-area-inset-bottom)) max(10px,env(safe-area-inset-left));',
		'font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}',
		'#cbx-multi-bar{display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;align-items:center;margin-bottom:10px}',
		'#cbx-multi-bar::-webkit-scrollbar{display:none}#cbx-multi input[type=text]{flex:1 1 120px;min-width:110px;max-width:260px;padding:9px 11px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;font-size:16px}',
		'#cbx-multi-bar .cbx-grow{flex:1 1 8px}',
		'#cbx-multi input[type=text]::placeholder{color:#8b969e}',
		'#cbx-multi .cbx-b{appearance:none;font:inherit;font-size:13px;padding:9px 12px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;cursor:pointer}',
		'#cbx-multi .cbx-b-accent{background:#f67300;border-color:#f67300;color:#fff}',
		'#cbx-multi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:8px}',
		'#cbx-multi-empty{color:#8b969e;font-size:13px;max-width:44ch}',
		'.cbx-cam{position:relative;background:#000;border:1px solid #2a3138;border-radius:10px;overflow:hidden;aspect-ratio:16/9}',
		'.cbx-cam.cbx-live-audio{border-color:#f67300}',
		'.cbx-cam video{width:100%;height:100%;object-fit:contain;background:#000;display:block;cursor:pointer}',
		'.cbx-cam .cbx-name{cursor:grab;position:absolute;left:8px;top:7px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.65);font-size:12px}',
		'.cbx-cam .cbx-state{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#8b969e;font-size:12px}',
		'.cbx-cam .cbx-subject{position:absolute;left:0;right:0;bottom:0;padding:3px 7px;background:rgba(0,0,0,.62);',
		'font:11px/1.35 system-ui,sans-serif;color:#cfd6db;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
		'#cbx-multi .cbx-ib{width:40px;height:40px;padding:0;display:inline-flex;align-items:center;justify-content:center}#cbx-multi .cbx-b svg{width:18px;height:18px;flex:none}#cbx-multi-add{display:inline-flex;align-items:center;gap:6px}#cbx-multi-add svg{width:16px;height:16px}',
		'#cbx-multi .cbx-it{display:inline-flex;align-items:center;gap:6px;height:40px;padding:0 10px;white-space:nowrap;flex:none}#cbx-multi .cbx-it.cbx-on{background:#2a3138;border-color:#f67300;color:#fff}#cbx-multi .cbx-b{flex:none}#cbx-multi-bar .cbx-grow{flex:1 1 4px;min-width:0}',
		'#cbx-multi .cbx-chev{width:14px!important;height:14px!important;margin-left:-2px;opacity:.7}',
		'#cbx-multi-cols-short{display:none;font-weight:600;font-size:12px}' +
		'@media (max-width:1400px){#cbx-multi .cbx-it span{display:none}#cbx-multi .cbx-it{width:40px;padding:0;justify-content:center}#cbx-multi .cbx-chev{display:none}#cbx-multi-cols{width:auto;padding:0 9px;gap:5px}#cbx-multi-cols-short{display:inline}}',
		'.cbx-ddwrap{flex:none}.cbx-mini{position:fixed;z-index:30;min-width:170px;padding:6px;border-radius:10px;border:1px solid #2a3138;background:#161a1e;box-shadow:0 10px 30px rgba(0,0,0,.5);display:flex;flex-direction:column;gap:2px}.cbx-mini button{text-align:left;border:0;border-radius:6px;background:transparent;color:#e8ebed;padding:8px 10px;cursor:pointer;font:inherit;font-size:13px}.cbx-mini button:hover{background:#252b31}.cbx-mini button.cbx-on{background:#2a3138;box-shadow:inset 3px 0 0 #f67300}.cbx-mini hr{border:0;border-top:1px solid #2a3138;margin:4px 2px}.cbx-mini button small{display:block;color:#8b969e;font-size:11px;margin-top:2px}',
		'.cbx-setswrap{flex:none}#cbx-multi-setsmenu{position:fixed;z-index:30;min-width:260px;padding:8px;border-radius:10px;border:1px solid #2a3138;background:#161a1e;box-shadow:0 10px 30px rgba(0,0,0,.5)}',
		'#cbx-multi-setsmenu .cbx-setrow{display:flex;gap:4px;align-items:stretch;margin-bottom:4px}#cbx-multi-setsmenu .cbx-setload{flex:1;text-align:left;border:0;border-radius:6px;background:#1c2126;color:#e8ebed;padding:8px 10px;cursor:pointer;font:inherit;font-size:13px}#cbx-multi-setsmenu .cbx-setload small{display:block;color:#8b969e;font-size:11px}#cbx-multi-setsmenu .cbx-setload:hover{background:#252b31}',
		'#cbx-multi-setsmenu .cbx-setdel{width:34px;border:0;border-radius:6px;background:#1c2126;color:#8b969e;cursor:pointer}#cbx-multi-setsmenu .cbx-setdel:hover{color:#fff;background:#3a2222}#cbx-multi-setsmenu .cbx-setdel svg{width:14px;height:14px}#cbx-multi-setsmenu #cbx-setsave{margin-top:6px}#cbx-multi-setsmenu .cbx-note{margin:4px 2px 8px}',
		'#cbx-multi.cbx-fill{overflow:hidden}#cbx-multi.cbx-fill .cbx-cam{aspect-ratio:auto;height:100%}#cbx-multi.cbx-fill #cbx-multi-grid{align-content:start}',
		'.cbx-cam[data-status]:not([data-status="public"]):not([data-status=""]):not([data-status="offline"])::after{content:attr(data-status);position:absolute;left:8px;bottom:8px;padding:2px 7px;border-radius:4px;background:rgba(200,60,60,.85);color:#fff;font:600 11px/1.4 system-ui,sans-serif;text-transform:capitalize;pointer-events:none}',
		'.cbx-cam .cbx-tools{position:absolute;right:6px;top:6px;display:flex;gap:4px;opacity:0;transition:opacity .15s ease}.cbx-cam:hover .cbx-tools,.cbx-cam.cbx-paused .cbx-tools,.cbx-cam.cbx-show .cbx-tools{opacity:1}',
		'.cbx-cam .cbx-tb{width:30px;height:30px;border:0;border-radius:15px;background:rgba(0,0,0,.65);color:#fff;display:inline-flex;align-items:center;justify-content:center;cursor:pointer}.cbx-cam .cbx-tb svg{width:16px;height:16px}',
		/* lightbox: dimmed backdrop, the picture as large as fits, click outside to close */
		'#cbx-multi-big{position:fixed;left:var(--cbx-vx,0px);top:var(--cbx-vy,0px);width:var(--cbx-vw,100%);height:var(--cbx-vh,100%);z-index:20;background:rgba(6,8,10,.92);display:flex;align-items:center;justify-content:center;cursor:zoom-out}#cbx-multi-big video{max-width:94vw;max-height:90vh;width:94vw;height:auto;aspect-ratio:16/9;object-fit:contain;background:#000;border-radius:8px;box-shadow:0 12px 40px rgba(0,0,0,.6);cursor:default}',
		'#cbx-multi-big .cbx-big-top{position:absolute;left:max(12px,env(safe-area-inset-left,0px));right:max(12px,env(safe-area-inset-right,0px));top:max(10px,env(safe-area-inset-top,0px));display:flex;align-items:center;gap:8px;z-index:2}#cbx-multi-big .cbx-big-name{padding:5px 10px;border-radius:6px;background:rgba(0,0,0,.6);font-size:13px;margin-right:auto}',
		'#cbx-multi-big .cbx-big-pop,#cbx-multi-big .cbx-big-x{height:36px;border:0;border-radius:18px;background:rgba(0,0,0,.65);color:#fff;display:inline-flex;align-items:center;gap:6px;padding:0 12px;cursor:pointer;font:13px system-ui,sans-serif}#cbx-multi-big .cbx-big-x{width:36px;padding:0;justify-content:center}#cbx-multi-big .cbx-big-top svg{width:18px;height:18px}',
		'html.cbx-touch .cbx-cam .cbx-tb{width:40px;height:40px;border-radius:20px}html.cbx-touch #cbx-multi-big :is(.cbx-big-pop,.cbx-big-x){height:44px;border-radius:22px}html.cbx-touch #cbx-multi-big .cbx-big-x{width:44px}',
		/* the way out stays in reach however far the toolbar is scrolled */
		'#cbx-multi-exit{position:sticky;right:0;z-index:2;box-shadow:-10px 0 8px -2px #0f1215}',
		'.cbx-cam.cbx-dragging{opacity:.4}.cbx-cam.cbx-drop-before{box-shadow:-4px 0 0 #f67300}.cbx-cam.cbx-drop-after{box-shadow:4px 0 0 #f67300}',
		'#cbx-multi.cbx-bare{padding:0}#cbx-multi.cbx-bare #cbx-multi-bar{position:fixed;left:0;right:0;top:0;z-index:5;margin:0;padding:8px;background:rgba(15,18,21,.92);transform:translateY(-100%);transition:transform .15s ease}',
		'#cbx-multi.cbx-bare #cbx-multi-bar:hover,#cbx-multi.cbx-bare #cbx-multi-hot:hover + #cbx-multi-bar{transform:none}',
		'#cbx-multi-hot{display:none}#cbx-multi.cbx-bare #cbx-multi-hot{display:block;position:fixed;left:0;right:0;top:0;height:10px;z-index:4}',
		'#cbx-multi.cbx-bare #cbx-multi-grid{gap:2px}#cbx-multi.cbx-bare .cbx-cam{border-radius:0;border:0}',
		'#cbx-multi-grid[data-cols="2"]{grid-template-columns:repeat(2,1fr)}#cbx-multi-grid[data-cols="3"]{grid-template-columns:repeat(3,1fr)}',
		'#cbx-multi-grid[data-cols="4"]{grid-template-columns:repeat(4,1fr)}#cbx-multi-grid[data-cols="5"]{grid-template-columns:repeat(5,1fr)}#cbx-multi-grid[data-cols="6"]{grid-template-columns:repeat(6,1fr)}#cbx-multi-grid[data-cols="7"]{grid-template-columns:repeat(7,1fr)}#cbx-multi-grid[data-cols="8"]{grid-template-columns:repeat(8,1fr)}#cbx-multi-grid[data-cols="1"]{grid-template-columns:1fr}',
		'@media (prefers-reduced-motion:reduce){#cbx-multi.cbx-bare #cbx-multi-bar{transition:none}}'
	].join('');

	var GEAR_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>';

	var TABS = [
		{ id: 'rewind', label: 'Rewind', groups: [
			{ title: null, items: [
				['siteDvr', 'Rewind', 'Holds minutes of the stream you can scrub back through, from one download.'],
				selectHTML('cbx-site-keep', 'Rewind window: ', [[120, '2 minutes'], [300, '5 minutes'], [600, '10 minutes']], S.siteKeep),
				['scrubThumbs', 'Preview frames on the timeline', 'A small frame every 10 seconds while you hover or drag.'],
				['tipMarks', 'Tip marks on the timeline', 'A yellow tick where each tip landed, so you can rewind to it.'],
				selectHTML('cbx-engine', 'Player on phones: ', [['auto', 'automatic'], ['adopt', "one player (the site's own video)"], ['overlay', 'overlay player (as before 2.1)']], S.engine),
				['nativeScrub', "Let Apple's fullscreen player scrub the rewind window", 'Experimental: widens what the native scrubber can reach to everything that is held.'],
				'<div class="cbx-mono" id="cbx-sitedvr-note"></div>' +
				'<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-siteplayer">Plain site player (this page)</button>'
			] }
		] },
		{ id: 'playback', label: 'Playback', groups: [
			{ title: 'Quality', items: [
				selectHTML('cbx-quality-cap', 'Quality: ', [[0, 'best available'], [1080, 'up to 1080p'], [720, 'up to 720p'], [480, 'up to 480p']], S.qualityCap),
				['autoQuality', 'Always use the best quality', 'Re-applies it when the player drops back to Auto.'],
				selectHTML('cbx-datasaver', 'Data saver: ', [['off', 'off'], ['auto', 'on cellular or low battery'], ['on', 'always']], S.dataSaver ? 'on' : S.dataSaverAuto ? 'auto' : 'off'),
				['errorQuality', 'Drop quality when the stream keeps stalling', null],
				'<div class="cbx-mono" id="cbx-quality-note"></div>'
			] },
			{ title: 'Background tabs', items: [
				selectHTML('cbx-bgtabs', 'When this tab is hidden: ', [['play', 'keep playing'], ['quality', 'drop quality'], ['pause', 'pause']], S.inactivePause ? 'pause' : S.inactiveQuality ? 'quality' : 'play'),
				['inactiveLoad', 'Start streams in background tabs', 'Rooms opened in a new tab load muted instead of waiting.'],
				['autoRecover', 'Recover the video when you come back', 'Restarts a stream that froze while the tab was in the background, without reloading the page.'],
				['phoneSleep', 'Release the player in background tabs (phones)', 'After about 25 seconds a hidden tab lets go of its decoder and buffer, and reconnects fresh when you come back. A quick look at another tab keeps everything.']
			] },
			{ title: 'Extras', items: [
				['mediaSession', 'Lock screen and headset controls', null],
				['showDuration', 'Show stream time', null],
				['pipButton', 'Picture in picture', 'In the player menu (Tools), and Alt+P.']
			] }
		] },
		{ id: 'controls', label: 'Controls', groups: [
			{ title: 'Keyboard and touch', items: [
				['keyShortcuts', 'Keyboard shortcuts', 'Press ? on a room page for the list.'],
				['dblTapSeek', 'Double-tap to skip', 'Left third −10s, right third +10s, middle fullscreen.'],
				['swipeSeek', 'Swipe to rewind (phones)', null],
				['holdFast', 'Hold for 2× (phones)', null],
				['tapToggles', 'Tap shows and hides the controls (touch)', 'The centre button pauses. Off: a tap on the picture pauses, as before.'],
				['backCloses', 'Back closes viewers and fullscreen (touch)', 'The stream-only viewer, the filled screen and an enlarged multi cam tile.']
			] },
			{ title: 'On the picture', items: [
				['fsAutoHide', 'Hide controls in fullscreen', null],
				['rotateFullscreen', 'Rotate the phone for fullscreen', 'Landscape fills the screen; portrait brings the page back.'],
				['statsOverlay', 'Stats overlay', 'Resolution, bitrate, latency, buffers and dropped frames (I).']
			] }
		] },
		// the same controls as the player's gear > Sound menu: that one is a tap away while watching, this one
		// is where people look for settings. Both write the same values; each shows what the other changed.
		{ id: 'sound', label: 'Sound', groups: [
			{ title: 'Cam audio', items: [
				rangeHTML('cbx-p-boost', 'Boost', 100, 400, 10, S.volumeBoost, '%'),
				['voiceBoost', 'Voice boost', 'Trims rumble and lifts speech.'],
				['normalize', 'Even out the volume', 'Lifts quiet voices, holds tip sounds and music down. On a phone the sound may wait for a tap after you switch tabs.'],
				'<small class="cbx-note" id="cbx-p-boost-note"></small>'
			] },
			{ title: 'Tip and alert sounds', items: [
				['blockSoundFx', 'Mute tip and alert sounds', 'Blocks the effects outright. Cam audio is untouched.'],
				['tipSliderZero', 'Set the site\'s Tip Volume', 'Drives the slider in the site\'s chat settings to the level chosen here.'],
				rangeHTML('cbx-p-tipvol', 'Tip volume', 0, 100, 5, S.tipVolume, '%')
			] },
			{ title: 'Other tabs', items: [
				['bgMute', 'Mute when this tab is hidden', null],
				['exclusiveAudio', 'Only one tab plays sound', 'Unmuting here mutes the other CB tabs.']
			] }
		] },
		{ id: 'rooms', label: 'Rooms', groups: [
			{ title: 'Room pages', items: [
				['bioInfo', 'Show extra room info', 'Country, region, time online, private and spy prices, fan club price, joined date.'],
				['autoChatRules', 'Accept room rules automatically', null],
				['trackSchedule', 'Remember when rooms are live', null],
				['time24', '24 hour times', null]
			] },
			{ title: 'Room cards', items: [
				['cardTools', 'Menu on room cards', 'Hide cam, hide country, note, alert, add to multi cam.'],
				['cardWatchBtn', 'Watch-without-chat button on cards', null],
				['openNewTab', 'Open rooms in a new tab', null],
				['randomLink', 'Random room link in the header', 'Uses the genders picked in the Multi tab.']
			] },
			{ title: 'Previews', items: [
				selectHTML('cbx-previews', 'Preview on hover: ', [['off', 'off'], ['inline', 'in the thumbnail'], ['box', 'in a corner box']], !S.hoverPreview ? 'off' : S.previewInline ? 'inline' : 'box'),
				['previewMuted', 'Previews start muted', null],
				['inlinePreview', 'Keep previews inline', 'Stops previews jumping to fullscreen on iOS.']
			] }
		] },
		{ id: 'chat', label: 'Chat', groups: [
			{ title: 'Translate', items: [
				['translateChat', 'Translate messages', null],
				selectHTML('cbx-tr-lang', 'Into ', LANGS, S.translateTo)
			] },
			{ title: 'Hide', items: [
				['chatHideNotices', 'Room notices', null],
				['chatHideSubject', 'Subject changes', null],
				['chatHideTips', 'Tip messages', null],
				['chatHideGreys', 'Grey users', null],
				['chatTipsOnly', 'Everything but tips', null]
			] },
			{ title: 'Text', items: [
				selectHTML('cbx-chat-font', 'Chat text: ', [[0, 'site default'], [12, 'small'], [14, 'medium'], [16, 'large'], [18, 'extra large']], S.chatFont)
			] }
		] },
		{ id: 'look', label: 'Look', groups: [
			{ title: 'Theme', items: [
				['forceDark', 'Dark theme', null],
				['darkLegacy', 'Dark theme on the older pages too', 'Fan club, supporter, followers and account forms.'],
				['tightMargins', 'Tighter page margins', null],
				['cleanProfile', 'Flatten profile styling', 'Strips absolute positioning, backgrounds and animation from bios.']
			] },
			{ title: 'Hide clutter', items: [
				['hideAds', 'Ads and banners', null],
				['hideSocials', 'Social links', null],
				['hideMerch', 'Merch links', null],
				['hideSurveys', 'Surveys and feedback prompts', null],
				['hidePlayerLogo', 'Logo on the player', null],
				['hideBadges', 'Thumbnail badges', null]
			] },
			{ title: 'Room cards', items: [
				selectHTML('cbx-grid', 'Card size: ', [[0, 'site default'], [150, 'small'], [200, 'medium'], [260, 'large'], [340, 'extra large']], S.gridSize),
				selectHTML('cbx-grid-more', 'Cards under a stream: ', [[0, 'same as above'], [150, 'small'], [200, 'medium'], [260, 'large'], [340, 'extra large']], S.moreGridSize)
			] },
			{ title: 'Who to show', items: [
				['hideGenderF', 'Hide women', null],
				['hideGenderM', 'Hide men', null],
				['hideGenderC', 'Hide couples', null],
				['hideGenderT', 'Hide trans', null]
			] }
		] },
		{ id: 'multi', label: 'Multi cam', groups: [
			{ title: null, items: [
				['multiCam', 'Enable multi cam', 'Opens in its own tab.'],
				['multiSameTab', 'Open in this tab on phones', 'No new tab: Back returns to where you were.'],
				['multiShowSubject', 'Show room subjects', null],
				['multiHideOffline', 'Hide cams that are offline', null],
				['multiHidePrivate', 'Hide cams in private, away or password shows', 'Off shows them with a badge; they come back by themselves when the show ends.'],
				['multiAutoRemove', 'Drop offline cams from the list', null],
				['multiHoverAudio', 'Play audio while hovering a tile', null]
			] }
		] },
		{ id: 'alerts', label: 'Alerts', groups: [
			{ title: null, items: [
				['alertsOn', 'Tell me when a room goes live', 'Checks your alert list in the background.']
			] }
		] },
		{ id: 'panel', label: 'More', groups: [
			{ title: 'This panel', items: [
				['showLauncher', 'Show the floating button', null],
				['launcherDrawer', 'Tuck the floating buttons into a drawer (touch)', 'A small tab on the right edge: tap it and the buttons slide out. Drag the tab up or down to move it.'],
				['edgeSwipe', 'Open by swiping from the right edge', null]
			] },
			{ title: 'Advanced', items: [
				['debug', 'Log to console', null],
				'<div class="cbx-b-pair"><button class="cbx-b" id="cbx-diag-share">Share diagnostics</button><button class="cbx-b cbx-b-quiet" id="cbx-diag-copy">Copy</button><button class="cbx-b cbx-b-quiet" id="cbx-diag-show">Show</button></div>' +
				'<small class="cbx-note">What the script saw on this page: screen and player measurements, the player\'s structure and the last few hundred log lines. The room name and address parameters are left out. Nothing is sent anywhere.</small>' +
				'<pre id="cbx-diag"></pre>'
			] }
		] }
	];

	function rowHTML(key, label, note) {
		return '<label class="cbx-row"><span>' + label +
			(note ? '<br><small class="cbx-note">' + note + '</small>' : '') +
			'</span><input type="checkbox" data-cbx="' + key + '"' + (S[key] ? ' checked' : '') + '><i class="cbx-sw"></i></label>';
	}

	function selectHTML(id, label, options, current) {
		// values may be numbers or strings; comparison below is string-based
		return '<select class="cbx-sel" id="' + id + '">' + options.map(function (o) {
			return '<option value="' + o[0] + '"' + (String(current) === String(o[0]) ? ' selected' : '') + '>' + label + o[1] + '</option>';
		}).join('') + '</select>';
	}

	function tabExtras(id) {
		if (id === 'sound') return '<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-tip-check">Check tip volume</button><div class="cbx-mono" id="cbx-tip-status"></div><div class="cbx-mono" id="cbx-tabs-debug"></div>';

		if (id === 'look') return '<button class="cbx-b cbx-b-wide" id="cbx-minimal">Apply clean look</button>' +
			'<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-pick">Hide an element…</button><div id="cbx-hidden-list"></div>';

		if (id === 'rooms') return '<h3>When this room is usually live</h3><div id="cbx-sched"></div>' +
			'<div id="cbx-blocked-list"></div><div id="cbx-cc-list"></div>';

		if (id === 'chat') return '<h3>Highlight keywords</h3><textarea class="cbx-ta" id="cbx-chat-kw" rows="2" placeholder="one per line or comma-separated">' + esc(S.chatKeywords || '') + '</textarea>' +
			'<h3>Muted users</h3><textarea class="cbx-ta" id="cbx-chat-mute" rows="3" placeholder="usernames, one per line">' + esc((S.chatMuted || []).join('\n')) + '</textarea>' +
			'<button class="cbx-b cbx-b-wide" id="cbx-scan">Who in chat is broadcasting</button>';

		if (id === 'multi') return selectHTML('cbx-multi-quality', 'Max ',
			[[1080, '1080p'], [720, '720p'], [480, '480p'], [360, '360p']], S.multiMaxHeight) +
			'<div id="cbx-multi-actions"><button class="cbx-b cbx-b-wide" id="cbx-open-multi">Open multi cam tab</button>' +
			'<h3>Fill with random rooms</h3><div class="cbx-chips" id="cbx-genders">' +
			[['f', 'Women'], ['m', 'Men'], ['c', 'Couples'], ['t', 'Trans']].map(function (g) {
				return '<button class="cbx-chip' + (S.randomGenders.indexOf(g[0]) !== -1 ? ' cbx-on' : '') +
					'" data-gender="' + g[0] + '">' + g[1] + '</button>';
			}).join('') + '</div>' +
			selectHTML('cbx-random-count', 'Add ', [[3, '3 rooms'], [6, '6 rooms'], [9, '9 rooms'], [12, '12 rooms']], S.randomCount) +
			'<button class="cbx-b cbx-b-wide" id="cbx-random">Add random rooms</button></div>';

		if (id === 'alerts') return selectHTML('cbx-alert-every', 'Check every ',
			[[30, '30 seconds'], [60, 'minute'], [180, '3 minutes'], [600, '10 minutes']], S.alertEvery) +
			'<div id="cbx-alert-list"></div>';

		if (id === 'panel') return '<h3>Language and backup</h3><small class="cbx-note">Settings are kept in this browser\'s storage for chaturbate.com. They survive reloads and updates of the script, not a clear of site data or a different browser — Export makes a file you can Import anywhere.</small>' + selectHTML('cbx-uilang', 'Panel language: ',
			[['auto', 'match my browser'], ['en', 'English']].concat(LANGS.filter(function (l) { return l[0] !== 'en'; })), S.uiLang) +
			'<div class="cbx-b-pair"><button class="cbx-b" id="cbx-export">Export</button>' +
			'<button class="cbx-b" id="cbx-import">Import</button></div>' +
			'<input type="file" id="cbx-import-file" accept="application/json" hidden>' +
			'<button class="cbx-b cbx-b-wide" id="cbx-recommended">Apply recommended settings</button>' +
			'<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-reset">Reset everything</button>';

		return '';
	}

	var panelEl = null, scrimEl = null, launcherEl = null, dockEl = null, multiDockEl = null;
	var GRID_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h6.5v6.5H4zm9.5 0H20v6.5h-6.5zM4 13.5h6.5V20H4zm9.5 0H20V20h-6.5z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/></svg>';
	var GRID_ADD_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 3h8v8H3zM3 13h8v8H3zm10 0h8v8h-8z"/><path d="M17 3v8M13 7h8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
	var GRID_IN_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 3h8v8H3zM3 13h8v8H3zm10 0h8v8h-8z"/><path d="M13.5 7l2.5 2.5L21 4" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';

	function buildUI() {
		if ($('#cbx-panel')) return;
		document.head.appendChild(el('style', { id: 'cbx-ui-css' }, UI_CSS));

		scrimEl = el('div', { id: 'cbx-scrim' });
		panelEl = el('div', { id: 'cbx-panel', role: 'dialog', 'aria-label': 'Chaturbate Enhanced Plus' });

		var nav = TABS.map(function (t, i) {
			return '<button role="tab" class="cbx-tab' + (i === 0 ? ' cbx-on' : '') + '" data-tab="' + t.id + '">' + t.label + '</button>';
		}).join('');

		var panes = TABS.map(function (t, i) {
			var inner = t.groups.map(function (g) {
				return (g.title ? '<h3>' + g.title + '</h3>' : '') +
					g.items.map(function (it) { return typeof it === 'string' ? it : rowHTML(it[0], it[1], it[2]); }).join('');
			}).join('');
			return '<div class="cbx-pane' + (i === 0 ? ' cbx-on' : '') + '" role="tabpanel" data-pane="' + t.id + '">' +
				inner + tabExtras(t.id) + '<button class="cbx-b cbx-b-wide cbx-b-quiet cbx-tab-reset" data-reset-tab="' + t.id + '">Reset this tab</button></div>';
		}).join('');

		panelEl.innerHTML =
			'<div id="cbx-grip"></div>' +
			'<div id="cbx-head"><h1>Enhanced Plus <small style="font-weight:400;font-size:11px;opacity:.6">' + VERSION + '</small></h1><button class="cbx-b" id="cbx-close" aria-label="Close">&times;</button></div>' +
			'<div id="cbx-tabs" role="tablist">' + nav + '</div>' +
			'<div id="cbx-filter-wrap"><input type="search" id="cbx-filter" placeholder="Filter settings…" autocomplete="off" spellcheck="false"></div>' +
			'<div id="cbx-body"><div id="cbx-secpick-wrap"><label for="cbx-secpick">Section</label><select id="cbx-secpick">' +
			TABS.map(function (t) { return '<option value="' + t.id + '">' + t.label + '</option>'; }).join('') + '</select></div>' + panes + '</div>';

		panelEl.addEventListener('change', function (e) {
			if (e.target.id !== 'cbx-secpick') return;
			var id = e.target.value;
			$$('.cbx-tab', panelEl).forEach(function (b) { b.classList.toggle('cbx-on', b.getAttribute('data-tab') === id); });
			$$('#cbx-body .cbx-pane', panelEl).forEach(function (s) { s.classList.toggle('cbx-on', s.getAttribute('data-pane') === id); });
		});
		panelEl.addEventListener('click', function (e) {
			var t = e.target.closest('.cbx-tab');
			if (!t) return;
			var id = t.getAttribute('data-tab');
			$$('.cbx-tab', panelEl).forEach(function (b) { b.classList.toggle('cbx-on', b === t); });
			$$('[data-pane]', panelEl).forEach(function (p) { p.classList.toggle('cbx-on', p.getAttribute('data-pane') === id); });
			$('#cbx-body', panelEl).scrollTop = 0;
			try { localStorage.setItem('cbx-panel-tab', id); } catch (err) {}
			if (id === 'rooms') renderSchedule();
			try { t.scrollIntoView({ block: 'nearest', inline: 'center' }); } catch (err) {}
		});

		document.body.appendChild(scrimEl);
		document.body.appendChild(panelEl);

		launcherEl = el('div', { id: 'cbx-launcher', role: 'button', tabindex: '0', 'aria-label': 'Chaturbate Enhanced Plus settings' }, GEAR_SVG);
		document.body.appendChild(launcherEl);
		dockEl = el('button', { id: 'cbx-dock', type: 'button', title: 'Enhanced player on/off (P)' }, '');
		dockEl.addEventListener('click', function (e) { e.preventDefault(); togglePlayer(); });
		document.body.appendChild(dockEl);
		// multi cam: one button adds or removes this room, the other opens the viewer (with the count)
		multiDockEl = el('span', { id: 'cbx-multidock' },
			'<button type="button" id="cbx-multi-toggle" title="Add to multi cam">' + GRID_ADD_SVG + '</button>' +
			'<button type="button" id="cbx-multi-open" title="Open the multi cam viewer">' + GRID_SVG + '<b></b></button>');
		$('#cbx-multi-toggle', multiDockEl).addEventListener('click', function (e) {
			e.preventDefault();
			var user = roomName(); if (!user) return;
			var list = jsonGet(MULTI_KEY, []), i = list.indexOf(user);
			if (i === -1) { list.push(user); toast('Added to multi cam (' + list.length + ')'); } else { list.splice(i, 1); toast('Removed from multi cam'); }
			jsonSet(MULTI_KEY, list); placeDock();
		});
		$('#cbx-multi-open', multiDockEl).addEventListener('click', function (e) { e.preventDefault(); openMulti(); });
		document.body.appendChild(multiDockEl);
		placeLauncher();
		makeDraggable(launcherEl);
		setDrawer(false);
		window.addEventListener('resize', placeDock);

		scrimEl.addEventListener('click', function () { openPanel(false); });
		$('#cbx-close', panelEl).addEventListener('click', function () { openPanel(false); });
		try {
			var lastTab = localStorage.getItem('cbx-panel-tab'), lastBtn = lastTab && $('.cbx-tab[data-tab="' + lastTab + '"]', panelEl);
			if (lastBtn) lastBtn.click();
		} catch (e) {}

		panelEl.addEventListener('change', function (e) {
			if (e.target.id === 'cbx-multi-quality') { S.multiMaxHeight = parseInt(e.target.value, 10); save(); return; }
			if (e.target.id === 'cbx-datasaver') { var ds = e.target.value; S.dataSaver = ds === 'on'; S.dataSaverAuto = ds === 'auto'; save(); onSettingChanged('dataSaver'); return; }
			if (e.target.id === 'cbx-bgtabs') { var bg = e.target.value; S.inactivePause = bg === 'pause'; S.inactiveQuality = bg === 'quality'; save(); return; }
			if (e.target.id === 'cbx-previews') { var pv = e.target.value; S.hoverPreview = pv !== 'off'; S.previewInline = pv === 'inline'; save(); onSettingChanged('hoverPreview'); onSettingChanged('previewInline'); return; }
			if (e.target.id === 'cbx-quality-cap') {
				S.qualityCap = parseInt(e.target.value, 10); save(); applyQuality(); return;
			}
			if (e.target.id === 'cbx-random-count') { S.randomCount = parseInt(e.target.value, 10); save(); return; }
			if (e.target.id === 'cbx-uilang') { S.uiLang = e.target.value; save(); localizePanel(); return; }
			if (e.target.id === 'cbx-tr-lang') { S.translateTo = e.target.value; trCache = {}; save(); return; }
			if (e.target.id === 'cbx-chat-font') { S.chatFont = parseInt(e.target.value, 10) || 0; save(); applyChatCSS(); return; }
			if (e.target.id === 'cbx-chat-kw') { S.chatKeywords = e.target.value; save(); rescanChat(); return; }
			if (e.target.id === 'cbx-chat-mute') {
				S.chatMuted = e.target.value.split(/[,\n]/).map(function (u) { return u.trim().toLowerCase(); }).filter(Boolean);
				save(); rescanChat(); return;
			}
			if (e.target.id === 'cbx-grid') { S.gridSize = parseInt(e.target.value, 10); save(); applySiteCSS(); return; }
			if (e.target.id === 'cbx-grid-more') { S.moreGridSize = parseInt(e.target.value, 10); save(); applySiteCSS(); return; }
			if (e.target.id === 'cbx-alert-every') { S.alertEvery = parseInt(e.target.value, 10); save(); startAlerts(); return; }
			if (e.target.id === 'cbx-engine') { S.engine = e.target.value; save(); onSettingChanged('engine'); return; }
			if (e.target.id === 'cbx-site-keep') { S.siteKeep = parseInt(e.target.value, 10) || 300; siteDvr.halvings = 0; if (P.eng) { var ct2 = P.eng.active(); if (ct2) fitBufferToMemory(ct2); } save(); refreshPanel(); return; }
			var k = e.target.getAttribute && e.target.getAttribute('data-cbx');
			if (!k) return;
			S[k] = !!e.target.checked;
			save();
			onSettingChanged(k);
		});

		panelEl.addEventListener('input', function (e) {
			var t = e.target;
			if (t.id === 'cbx-filter') { applyFilter(t.value); return; }
			var lbl = t.id ? $('#' + t.id + '-val', panelEl) : null;
			if (t.id === 'cbx-p-boost') { S.volumeBoost = Math.round(parseFloat(t.value)) || 100; if (lbl) lbl.textContent = S.volumeBoost + '%'; applyAudioChain(); soundNotes(); }
			else if (t.id === 'cbx-p-tipvol') { if (lbl) lbl.textContent = t.value + '%'; }
		});
		panelEl.addEventListener('change', function (e) {
			var t = e.target;
			if (t.id === 'cbx-p-boost') { save(); syncBar(); }
			else if (t.id === 'cbx-p-tipvol') { S.tipVolume = parseInt(t.value, 10) || 0; save(); tipDone = false; applyTipMute(0); }
		});
		$('#cbx-filter', panelEl).addEventListener('keydown', function (e) {
			if (e.key === 'Escape' && e.target.value) { e.target.value = ''; applyFilter(''); e.stopPropagation(); }
		});
		$('#cbx-tip-check', panelEl).addEventListener('click', function () {
			tipDone = false; tipRung = ''; applyTipMute(0);
			var show = function () { var st = $('#cbx-tip-status', panelEl); if (st) st.textContent = tipDiagnostics(); };
			show(); [800, 2000, 4000, 7000].forEach(function (ms) { setTimeout(show, ms); });
		});
		$('#cbx-siteplayer', panelEl).addEventListener('click', backToSitePlayer);
		$('#cbx-diag-copy', panelEl).addEventListener('click', function () {
			var txt = diagReport(), pre = $('#cbx-diag', panelEl);
			if (pre) pre.textContent = txt;
			copyText(txt, 'Diagnostics copied (' + Math.round(txt.length / 1024) + ' KB) — paste them where you report the problem', function () { if (pre) pre.classList.add('cbx-on'); });
		});
		// a phone's share sheet (Messages, Mail, Notes, "Copy") is far less fiddly than the clipboard
		var shareBtn = $('#cbx-diag-share', panelEl);
		if (shareBtn) {
			if (!navigator.share) shareBtn.style.display = 'none';
			shareBtn.addEventListener('click', function () {
				var txt = diagReport(), pre = $('#cbx-diag', panelEl); if (pre) pre.textContent = txt;
				try { navigator.share({ title: 'Chaturbate Enhanced Plus diagnostics', text: txt }).catch(function (e) { if (!e || e.name !== 'AbortError') copyText(txt, 'Diagnostics copied instead', function () { if (pre) pre.classList.add('cbx-on'); }); }); }
				catch (e) { copyText(txt, 'Diagnostics copied instead', function () { if (pre) pre.classList.add('cbx-on'); }); }
			});
		}
		$('#cbx-diag-show', panelEl).addEventListener('click', function () {
			var pre = $('#cbx-diag', panelEl); if (!pre) return;
			var on = !pre.classList.contains('cbx-on');
			if (on) pre.textContent = diagReport();
			pre.classList.toggle('cbx-on', on);
			this.textContent = on ? 'Hide' : 'Show';
		});
		$('#cbx-random', panelEl).addEventListener('click', function () { fillRandom(null); });
		$('#cbx-genders', panelEl).addEventListener('click', function (e) {
			var c = e.target.closest('.cbx-chip');
			if (!c) return;
			var g = c.getAttribute('data-gender');
			var cur = S.randomGenders.split('').filter(Boolean);
			var i = cur.indexOf(g);
			if (i === -1) cur.push(g); else cur.splice(i, 1);
			if (!cur.length) cur = [g];
			S.randomGenders = cur.join('');
			save();
			$$('.cbx-chip', panelEl).forEach(function (b) {
				b.classList.toggle('cbx-on', S.randomGenders.indexOf(b.getAttribute('data-gender')) !== -1);
			});
		});
		$('#cbx-scan', panelEl).addEventListener('click', function () { openPanel(false); scanChat(); });

		$('#cbx-minimal', panelEl).addEventListener('click', function () {
			MINIMAL_SET.forEach(function (k) { S[k] = true; });
			save(); applySiteCSS(); refreshPanel(); toast('Clean look applied');
		});
		$('#cbx-pick', panelEl).addEventListener('click', function () {
			openPanel(false); togglePicker(true); toast('Tap the thing you want gone');
		});
		$('#cbx-open-multi', panelEl).addEventListener('click', function () {
			openMulti();
		});

		$('#cbx-export', panelEl).addEventListener('click', exportAll);
		$('#cbx-import', panelEl).addEventListener('click', function () { $('#cbx-import-file', panelEl).click(); });
		$('#cbx-import-file', panelEl).addEventListener('change', importAll);
		// per-tab reset: every switch on the tab back to its default, plus the selects it carries
		var RANGE_KEYS = { 'cbx-p-boost': 'volumeBoost', 'cbx-p-tipvol': 'tipVolume' };
		var SEL_KEYS = { 'cbx-engine': ['engine'], 'cbx-site-keep': ['siteKeep'], 'cbx-quality-cap': ['qualityCap'], 'cbx-datasaver': ['dataSaver', 'dataSaverAuto'], 'cbx-bgtabs': ['inactivePause', 'inactiveQuality'],
			'cbx-previews': ['hoverPreview', 'previewInline'], 'cbx-tr-lang': ['translateTo'], 'cbx-chat-font': ['chatFont'], 'cbx-grid': ['gridSize'], 'cbx-grid-more': ['moreGridSize'],
			'cbx-multi-quality': ['multiMaxHeight'], 'cbx-random-count': ['randomCount'], 'cbx-alert-every': ['alertEvery'], 'cbx-uilang': ['uiLang'] };
		panelEl.addEventListener('click', function (e) {
			var b = e.target.closest('[data-reset-tab]'); if (!b) return;
			var tab = TABS.filter(function (t) { return t.id === b.getAttribute('data-reset-tab'); })[0], pane = b.closest('.cbx-pane'); if (!tab || !pane) return;
			var keys = [];
			tab.groups.forEach(function (g) { g.items.forEach(function (it) { if (typeof it !== 'string') keys.push(it[0]); }); });
			$$('select.cbx-sel', pane).forEach(function (s) { (SEL_KEYS[s.id] || []).forEach(function (k) { keys.push(k); }); });
			$$('input[type="range"]', pane).forEach(function (r) { if (RANGE_KEYS[r.id]) keys.push(RANGE_KEYS[r.id]); });
			var changed = [];
			keys.forEach(function (k) { if (k in DEFAULTS && JSON.stringify(S[k]) !== JSON.stringify(DEFAULTS[k])) { S[k] = JSON.parse(JSON.stringify(DEFAULTS[k])); changed.push(k); } });
			save();
			$$('select.cbx-sel', pane).forEach(function (s) { var ks = SEL_KEYS[s.id]; if (ks && ks.length === 1) s.value = String(S[ks[0]]); });
			refreshPanel(); applySiteCSS(); applyDark(); applyChatCSS(); decorateCards();
			changed.forEach(onSettingChanged);
			toast(changed.length ? 'Reset ' + changed.length + ' setting' + (changed.length === 1 ? '' : 's') : 'Already at defaults');
		});
		$('#cbx-recommended', panelEl).addEventListener('click', function () {
			if (!confirm('Set every option back to the recommended defaults? Notes, hidden cams and alerts are kept.')) return;
			var keepKeys = ['uiLang', 'showLauncher', 'randomGenders', 'randomCount'];
			for (var k in DEFAULTS) if (keepKeys.indexOf(k) === -1) S[k] = DEFAULTS[k];
			save(); applySiteCSS(); applyDark(); decorateCards(); refreshPanel();
			toast('Recommended settings applied');
		});
		$('#cbx-reset', panelEl).addEventListener('click', function () {
			if (!confirm('Reset all Enhanced Plus settings, notes and hidden cams?')) return;
			[KEY, MULTI_KEY, HIDE_KEY, BLOCK_KEY, NOTES_KEY, POS_KEY, WATCH_KEY, SEEN_KEY, CC_KEY].forEach(function (k) {
				try { localStorage.removeItem(k); } catch (e) {}
			});
			location.reload();
		});

		document.addEventListener('keydown', function (e) {
			if (e.target.matches && e.target.matches('input,textarea,[contenteditable]')) return;
			if (e.key === 'Escape') {
				if (pickerOn) { togglePicker(false); toast('Cancelled'); }
				else if ($('#cbx-watch')) closeWatchOnly();
				else openPanel(false);
			}
			if (e.altKey && /^c$/i.test(e.key)) { e.preventDefault(); openPanel(!panelEl.classList.contains('cbx-on')); }
			if (S.pipButton && e.altKey && /^p$/i.test(e.key)) { e.preventDefault(); togglePiP(); }
			if (e.key === 'ArrowLeft' && !e.altKey && !e.ctrlKey && !e.metaKey && roomName()) { e.preventDefault(); seekBack(e.shiftKey ? 60 : 15); }
			if (e.key === 'ArrowRight' && !e.altKey && !e.ctrlKey && !e.metaKey && roomName()) { e.preventDefault(); goLive(); }
		});

		installGestures();
		refreshPanel();
		setTimeout(localizePanel, 400);
	}

	function onSettingChanged(k) {
		if (k === 'voiceBoost' || k === 'normalize' || k === 'volumeBoost') { applyAudioChain(); soundNotes(); syncBar(); }
		if (k === 'tipVolume') { tipDone = false; applyTipMute(0); }
		if (k === 'launcherDrawer' || k === 'showLauncher') { setDrawer(false); placeLauncher(); }
		if (k === 'engine') {
			// asking for it again is a fresh start: forget that it gave up here
			A.blocked = false; A.why = ''; A.adoptions = []; A.told = false;
			try { sessionStorage.removeItem(ADOPT_FAILS_KEY); } catch (e) {}
			P.suspended = false; P.attempt = 0;
			removeDvr(); playerTick();
			if (k === 'engine') toast(S.engine === 'overlay' ? 'Overlay player' : S.engine === 'adopt' ? "One player: the site's own video" : 'Player: automatic');
		}
		if (k === 'nativeScrub' && P.video && P.eng) toast('Applies from the next time the player starts');
		if (k === 'phoneSleep' && !S.phoneSleep) clearTimeout(sleepTimer);
		if (k === 'siteDvr') { siteDvr.halvings = 0; removeDvr(); if (!S.siteDvr) clearSiteClock(); playerTick(); toast(S.siteDvr ? 'Rewind on' : 'Rewind off'); }
		if (k === 'dataSaver' || k === 'dataSaverAuto') { if (P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap())); updatePlayerToggle(); }
		if (k === 'statsOverlay') { var so = $('#cbx-stats'); if (so && !S.statsOverlay) so.remove(); updateBar(); }
		if (k === 'fsAutoHide') armIdleHide();
		if (k === 'chatTipsOnly') applyChatCSS();
		if (k === 'tipMarks') updateBar();
		if (k === 'clipSave') { removeBlock(); if (S.clipSave) toast('Reload the room once so the buffer is captured from the start'); }
		if (k === 'tipSliderZero' && S.tipSliderZero) { tipDone = false; applyTipMute(0); }
		if (k === 'bgMute' && !S.bgMute) applyBgMute(false);
		if (k === 'showLauncher' || k === 'multiCam') placeLauncher();
		if (k === 'randomLink') ensureRandomLink();
		if (k === 'bgMute' && S.bgMute && document.hidden) applyBgMute(true);
		if (k === 'forceDark') {
			if (S.forceDark) applyDark();
			else { document.body.classList.remove('darkmode'); document.documentElement.classList.remove('darkmode'); }
		}
		if (k === 'showDuration') installDuration();
		if (k === 'hoverPreview' && !S.hoverPreview) stopPreview();
		if (k === 'previewInline') stopPreview();
		if (k === 'cardWatchBtn') $$('.cbx-tools').forEach(function (t) { t.remove(); });
		if (k === 'bioInfo') { bioFor = null; renderBioInfo(); }
		if (k === 'alertsOn') startAlerts();
		if (k === 'autoQuality') { startQualityWatchdog(); if (S.autoQuality) applyQuality(); }
		applySiteCSS();
		decorateCards();
		refreshPanel();
	}


	function installDuration() {
		removeTick('duration');
		var old = $('#cbx-duration');
		if (!S.showDuration || !roomName()) { if (old) old.remove(); return; }
		addTick('duration', function () {
			var v = activeVideo();
			if (!v) return;
			var host = P.shell || v.closest('#cbx-watch') || v.closest(MAIN_PLAYER_SEL) || v.parentElement;
			if (!host) return;
			if (!host._cbxPosChecked) { host._cbxPosChecked = true; if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; }
			var badge = $('#cbx-duration');
			if (!badge) { badge = el('div', { id: 'cbx-duration' }); host.appendChild(badge); }
			var txt = fmtTime(v.currentTime); if (badge._txt !== txt) { badge._txt = txt; badge.textContent = txt; }
		}, 1000);
	}

	function listHTML(items, kind) {
		return items.map(function (v, i) {
			return '<div class="cbx-list-item"><b>' + esc(v) + '</b><button class="cbx-b cbx-b-quiet" data-un="' + kind + ':' + i + '">Undo</button></div>';
		}).join('');
	}

	// filtering flattens every tab into one list of matching rows
	function applyFilter(q) {
		q = (q || '').trim().toLowerCase();
		panelEl.classList.toggle('cbx-filtering', !!q);
		var panes = $$('.cbx-pane', panelEl), total = 0;
		panes.forEach(function (pane) {
			var any = false;
			Array.prototype.forEach.call(pane.children, function (c) {
				if (!q) { c.classList.remove('cbx-hide'); return; }
				var row = c.classList.contains('cbx-row') || c.classList.contains('cbx-sel-wrap') || c.classList.contains('cbx-range');
				var hit = row && (c.textContent || '').toLowerCase().indexOf(q) > -1;
				c.classList.toggle('cbx-hide', !hit);
				if (hit) any = true;
			});
			pane.classList.toggle('cbx-match', any); if (any) total++;
		});
		var none = $('#cbx-filter-none', panelEl);
		if (q && !total) { if (!none) $('#cbx-body', panelEl).appendChild(el('div', { id: 'cbx-filter-none', 'class': 'cbx-note' }, 'Nothing matches.')); }
		else if (none) none.remove();
	}

	function refreshPanel() {
		if (!panelEl) return;
		$$('input[data-cbx]', panelEl).forEach(function (i) { i.checked = !!S[i.getAttribute('data-cbx')]; });
		var setSel = function (id, v) { var s = $('#' + id, panelEl); if (s && s.value !== v) s.value = v; };
		setSel('cbx-datasaver', S.dataSaver ? 'on' : S.dataSaverAuto ? 'auto' : 'off');
		setSel('cbx-bgtabs', S.inactivePause ? 'pause' : S.inactiveQuality ? 'quality' : 'play');
		setSel('cbx-previews', !S.hoverPreview ? 'off' : S.previewInline ? 'inline' : 'box');
		setSel('cbx-engine', engineMode());
		[['cbx-p-boost', S.volumeBoost], ['cbx-p-tipvol', S.tipVolume]].forEach(function (p) {
			var r = $('#' + p[0], panelEl), l = $('#' + p[0] + '-val', panelEl);
			if (r && document.activeElement !== r && String(r.value) !== String(p[1])) r.value = p[1];
			if (l) l.textContent = p[1] + '%';
		});
		soundNotes();

		var acts = $('#cbx-multi-actions', panelEl);
		if (acts) acts.style.display = S.multiCam ? 'block' : 'none';

		var qn = $('#cbx-quality-note', panelEl);
		if (qn) {
			var dvrV = $('video.cbx-dvr') || (P.adopted ? P.video : null);
			var engine = requiredHls() ? 'hls.js ready' : 'hls.js not loaded';
			var win = dvrV && dvrV.seekable && dvrV.seekable.length
				? Math.round(dvrV.seekable.end(dvrV.seekable.length - 1) - dvrV.seekable.start(0)) + 's rewind window'
				: (dvrV ? 'no rewind window' : 'deep rewind off');
			qn.textContent = 'Quality: ' + qualityNote + '\n' + engine + ' · ' + win;
		}
		var sn = $('#cbx-sitedvr-note', panelEl);
		if (sn) sn.textContent = siteDvrNote();

		var st = $('#cbx-tip-status', panelEl);
		if (st && !st.textContent) st.textContent = 'Tip volume: ' + tipStatus;

		var hidden = jsonGet(HIDE_KEY, []), box = $('#cbx-hidden-list', panelEl);
		if (box) box.innerHTML = hidden.length ? '<p class="cbx-note">Hidden by you</p>' + listHTML(hidden, 'sel') : '';

		var al = alertList(), abox = $('#cbx-alert-list', panelEl);
		if (abox) abox.innerHTML = al.length ? '<p class="cbx-note">Alerting for</p>' + listHTML(al, 'alert') : '';

		var block = jsonGet(BLOCK_KEY, []), bbox = $('#cbx-blocked-list', panelEl);
		if (bbox) bbox.innerHTML = block.length ? '<p class="cbx-note">Hidden cams</p>' + listHTML(block, 'room') : '';

		var ccs = hiddenCountries(), cbox = $('#cbx-cc-list', panelEl);
		if (cbox) cbox.innerHTML = ccs.length ? '<p class="cbx-note">Hidden countries</p>' + listHTML(ccs.map(countryName), 'cc') : '';

		$$('[data-un]', panelEl).forEach(function (b) {
			b.addEventListener('click', function () {
				var p = b.getAttribute('data-un').split(':');
				var key = { sel: HIDE_KEY, alert: WATCH_KEY, cc: CC_KEY }[p[0]] || BLOCK_KEY;
				var l = jsonGet(key, []);
				l.splice(parseInt(p[1], 10), 1);
				jsonSet(key, l);
				applySiteCSS(); decorateCards(); refreshPanel();
			});
		});
	}

	function renderSchedule() {
		var box = $('#cbx-sched', panelEl);
		if (box) box.innerHTML = scheduleHTML(roomName());
	}

	function exportAll() {
		var data = {
			settings: S,
			multi: jsonGet(MULTI_KEY, []),
			hidden: jsonGet(HIDE_KEY, []),
			blocked: jsonGet(BLOCK_KEY, []),
			notes: jsonGet(NOTES_KEY, {}),
			alerts: jsonGet(WATCH_KEY, []),
			countries: hiddenCountries(),
			seen: jsonGet(SEEN_KEY, {}),
			sets: jsonGet(MULTI_SETS_KEY, {}),
			launcher: jsonGet(POS_KEY, null)
		};
		var url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
		var a = el('a'); a.href = url; a.download = 'chaturbate-enhanced-plus.json'; a.click();
		// a phone asks "Download?" first and only reads the blob after the answer: 2s was gone by then
		setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
		toast('Settings exported');
	}

	function importAll(e) {
		var f = e.target.files && e.target.files[0];
		if (!f) return;
		var fr = new FileReader();
		fr.onload = function () {
			try {
				var d = JSON.parse(fr.result);
				if (d.settings) { for (var k in DEFAULTS) if (typeof d.settings[k] === typeof DEFAULTS[k]) S[k] = d.settings[k]; save(); }
				if (d.multi) jsonSet(MULTI_KEY, d.multi);
				if (d.hidden) jsonSet(HIDE_KEY, d.hidden);
				if (d.blocked) jsonSet(BLOCK_KEY, d.blocked);
				if (d.notes) jsonSet(NOTES_KEY, d.notes);
				if (d.alerts) jsonSet(WATCH_KEY, d.alerts);
				if (d.countries) jsonSet(CC_KEY, d.countries);
				if (d.seen) jsonSet(SEEN_KEY, d.seen);
				// saved sets are merged by name: importing a backup never deletes a set made since
				if (d.sets && typeof d.sets === 'object') { var sets = jsonGet(MULTI_SETS_KEY, {}); for (var n in d.sets) if (Array.isArray(d.sets[n])) sets[n] = d.sets[n]; jsonSet(MULTI_SETS_KEY, sets); }
				if (d.launcher && typeof d.launcher.x === 'number') { jsonSet(POS_KEY, d.launcher); placeLauncher(); }
				applySiteCSS(); decorateCards(); refreshPanel();
				toast('Settings imported');
			} catch (err) { toast('That file could not be read'); }
		};
		fr.readAsText(f);
		e.target.value = '';
	}

	function openPanel(on) {
		if (!panelEl) return;
		panelEl.classList.toggle('cbx-on', on);
		scrimEl.classList.toggle('cbx-on', on);
		if (on) { refreshPanel(); localizePanel(); setTimeout(checkTabs, 350); }
	}

	// the site's mobile stylesheet can collapse our tab strip; if it did,
	// rebuild the tabs with every style inline and say what was measured
	function checkTabs() {
		var strip = $('#cbx-tabs', panelEl); if (!strip) return;
		var r = strip.getBoundingClientRect(), first = $('.cbx-tab', strip), fr = first ? first.getBoundingClientRect() : { width: 0, height: 0 };
		var ok = r.height >= 10 && fr.width >= 10 && fr.height >= 10 && getComputedStyle(strip).display !== 'none';
		log('tabs', Math.round(r.width) + 'x' + Math.round(r.height), 'first tab', Math.round(fr.width) + 'x' + Math.round(fr.height), ok ? 'ok' : 'COLLAPSED');
		var dbg = $('#cbx-tabs-debug', panelEl), cs = getComputedStyle(strip), fcs = first ? getComputedStyle(first) : null;
		if (dbg) dbg.textContent = 'tabs strip: ' + Math.round(r.width) + 'x' + Math.round(r.height) + ' at ' + Math.round(r.left) + ',' + Math.round(r.top) +
			' disp=' + cs.display + ' vis=' + cs.visibility + ' op=' + cs.opacity + ' ovf=' + cs.overflow +
			' | first tab: ' + Math.round(fr.width) + 'x' + Math.round(fr.height) + (fcs ? ' color=' + fcs.color + ' bg=' + fcs.backgroundColor + ' font=' + fcs.fontSize : '') +
			' | panel ' + Math.round(panelEl.getBoundingClientRect().top) + ' head ' + Math.round($('#cbx-head', panelEl).getBoundingClientRect().bottom) + ' body ' + Math.round($('#cbx-body', panelEl).getBoundingClientRect().top);
		if (ok || strip._cbxFixed) return;
		strip._cbxFixed = true;
		var css = 'display:block!important;visibility:visible!important;opacity:1!important;height:auto!important;min-height:0!important;';
		strip.setAttribute('style', css + 'padding:6px 10px 8px;overflow:visible;');
		$$('.cbx-tab', strip).forEach(function (b) {
			b.setAttribute('style', 'display:inline-block!important;visibility:visible!important;opacity:1!important;height:auto!important;width:auto!important;' +
				'margin:2px 4px 2px 0;padding:8px 12px;border:1px solid #2a3138;border-radius:8px;background:#1b2128;color:#e8ebed;font:13px/1 system-ui,sans-serif;');
		});
		toast('Tabs were hidden by the site (' + Math.round(r.width) + 'x' + Math.round(r.height) + '); rebuilt', 4000);
	}

	/* Touch screens: four floating buttons have no good place. Wherever they sit they cover the picture
	 * or the chat. They are tucked into a drawer instead: a small see-through tab on the right edge (drag
	 * it up or down), a tap slides the buttons out, and they go back by themselves. */
	var drawerTimer = null;
	function drawerMode() { return !!S.launcherDrawer && !!S.showLauncher && wantsTouchLayout() && !IS_MULTI && !WATCH_POP; }
	function setDrawer(open) {
		var root = document.documentElement, on = drawerMode();
		root.classList.toggle('cbx-drawer', on);
		root.classList.toggle('cbx-drawer-open', on && !!open);
		clearTimeout(drawerTimer);
		if (on && open) drawerTimer = setTimeout(function () { setDrawer(false); }, 5000);
		placeDock();
		// once more when the new layout has settled (fonts, the pill's own width)
		requestAnimationFrame(placeDock);
	}
	document.addEventListener('pointerdown', function (e) {
		if (!document.documentElement.classList.contains('cbx-drawer-open')) return;
		var t = e.target, inside = t && t.closest && t.closest('#cbx-launcher,#cbx-dock,#cbx-multidock');
		if (inside) { clearTimeout(drawerTimer); drawerTimer = setTimeout(function () { setDrawer(false); }, 5000); }
		else setDrawer(false);
	}, true);
	function placeLauncher() {
		if (!launcherEl) return;
		launcherEl.style.display = S.showLauncher ? 'flex' : 'none';
		var p = jsonGet(POS_KEY, null);
		// clamp to the visual viewport so a saved spot never ends up under the browser's toolbar
		var vv = window.visualViewport, vw = vv ? vv.width : innerWidth, vh = vv ? vv.height : innerHeight;
		var pad = isTouch() ? 72 : 12;
		if (p && typeof p.x === 'number') {
			launcherEl.style.left = Math.min(Math.max(p.x, 4), vw - 44) + 'px';
			launcherEl.style.top = Math.min(Math.max(p.y, 4), vh - 44 - (isTouch() ? 28 : 0)) + 'px';
			launcherEl.style.right = 'auto'; launcherEl.style.bottom = 'auto';
		} else {
			launcherEl.style.left = 'auto'; launcherEl.style.top = 'auto';
			launcherEl.style.right = '12px'; launcherEl.style.bottom = 'calc(' + pad + 'px + env(safe-area-inset-bottom, 0px))';
		}
		placeDock();
	}
	// the on/off pill sits beside the gear and follows it around
	function placeDock() {
		if (!dockEl || !launcherEl) return;
		var inRoom = !!roomName(), show = inRoom && S.showLauncher;
		dockEl.style.display = show ? 'inline-flex' : 'none';
		var r = launcherEl.getBoundingClientRect(), w = dockEl.offsetWidth || 74, h = dockEl.offsetHeight || 30;
		var left = r.left - w - 8;
		if (left < 4) left = r.right + 8;
		if (show) {
			updatePlayerToggle();
			dockEl.style.left = Math.round(left) + 'px';
			dockEl.style.top = Math.round(r.top + (r.height - h) / 2) + 'px';
		}
		if (!multiDockEl) return;
		// the viewer button shows on every page; the add/remove button only where there is a room to add
		var mshow = S.showLauncher && !!S.multiCam && !IS_MULTI && !WATCH_POP;
		multiDockEl.style.display = mshow ? 'inline-flex' : 'none';
		if (!mshow) return;
		var list = jsonGet(MULTI_KEY, []), tg = $('#cbx-multi-toggle', multiDockEl), op = $('#cbx-multi-open', multiDockEl);
		tg.style.display = inRoom ? '' : 'none';
		if (inRoom) {
			var inList = list.indexOf(roomName()) !== -1;
			tg.classList.toggle('cbx-in', inList); tg.innerHTML = inList ? GRID_IN_SVG : GRID_ADD_SVG; tg.title = inList ? 'In multi cam — tap to remove' : 'Add to multi cam';
		}
		op.lastChild.textContent = list.length ? String(list.length) : ''; op.classList.toggle('cbx-has', !!list.length);
		op.title = list.length ? 'Open the multi cam viewer (' + list.length + ' room' + (list.length === 1 ? '' : 's') + ')' : 'Open the multi cam viewer';
		var mw = multiDockEl.offsetWidth || (inRoom ? 88 : 40), anchorLeft = show ? left : r.left, anchorRight = show ? left + w : r.right;
		var mleft = anchorLeft - mw - 8;
		if (mleft < 4) mleft = anchorRight + 8;
		multiDockEl.style.left = Math.round(mleft) + 'px';
		multiDockEl.style.top = Math.round(r.top + (r.height - 40) / 2) + 'px';
	}

	function makeDraggable(node) {
		var dragging = false, moved = false, offX = 0, offY = 0, sx = 0, sy = 0;
		function down(e) {
			var p = e.touches ? e.touches[0] : e;
			dragging = true; moved = false; sx = p.clientX; sy = p.clientY;
			var r = node.getBoundingClientRect();
			offX = p.clientX - r.left; offY = p.clientY - r.top;
		}
		function move(e) {
			if (!dragging) return;
			var p = e.touches ? e.touches[0] : e;
			// a finger is never perfectly still: a tap that wobbles a few pixels is still a tap
			if (!moved && Math.abs(p.clientX - sx) < 6 && Math.abs(p.clientY - sy) < 6) return;
			moved = true;
			node.style.left = (p.clientX - offX) + 'px';
			node.style.top = (p.clientY - offY) + 'px';
			node.style.right = 'auto'; node.style.bottom = 'auto';
			if (node === launcherEl) placeDock();
			e.preventDefault();
		}
		function up() {
			if (!dragging) return;
			dragging = false;
			if (moved) {
				var r = node.getBoundingClientRect();
				jsonSet(POS_KEY, { x: r.left, y: r.top });
				if (node === launcherEl && drawerMode()) placeLauncher();
			}
			// drawer: the first tap slides the buttons out, the gear then opens the panel as always
			else if (node === launcherEl && drawerMode() && !document.documentElement.classList.contains('cbx-drawer-open')) setDrawer(true);
			else { openPanel(!panelEl.classList.contains('cbx-on')); if (drawerMode()) setDrawer(false); }
		}
		node.addEventListener('mousedown', down);
		document.addEventListener('mousemove', move);
		document.addEventListener('mouseup', up);
		node.addEventListener('touchstart', down, { passive: true });
		document.addEventListener('touchmove', move, { passive: false });
		document.addEventListener('touchend', up);
		node.addEventListener('keydown', function (e) {
			if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(true); }
		});
	}

	var resizeTimer = null;
	window.addEventListener('resize', function () {
		clearTimeout(resizeTimer);
		resizeTimer = setTimeout(function () {
			placeLauncher();       // re-clamp into the new viewport
		}, 150);
	});

	function installGestures() {
		var startX = 0, startY = 0, tracking = false;
		document.addEventListener('touchstart', function (e) {
			if (!S.edgeSwipe || panelEl.classList.contains('cbx-on')) return;
			var t = e.touches[0];
			tracking = t.clientX > innerWidth - 24;
			startX = t.clientX; startY = t.clientY;
		}, { passive: true });
		document.addEventListener('touchmove', function (e) {
			if (!tracking) return;
			var t = e.touches[0];
			if (startX - t.clientX > 45 && Math.abs(t.clientY - startY) < 40) { tracking = false; openPanel(true); }
		}, { passive: true });

		var sy = 0, sheetDrag = false;
		panelEl.addEventListener('touchstart', function (e) {
			if (innerWidth >= 700) return;
			var t = e.touches[0];
			sheetDrag = t.clientY < panelEl.getBoundingClientRect().top + 48;
			sy = t.clientY;
		}, { passive: true });
		panelEl.addEventListener('touchmove', function (e) {
			if (!sheetDrag) return;
			if (e.touches[0].clientY - sy > 60) { sheetDrag = false; openPanel(false); }
		}, { passive: true });
	}

	var toastTimer = null;
	// while something is fullscreen only its subtree is painted, so our fixed
	// UI has to live inside it (a bare <video> cannot hold children — skip then)
	function uiHost() {
		var fs = document.fullscreenElement || document.webkitFullscreenElement;
		return fs && fs.tagName !== 'VIDEO' && fs.isConnected ? fs : document.body;
	}
	function rehomeUI() {
		var host = uiHost();
		[scrimEl, panelEl, launcherEl, dockEl, ddEl, $('#cbx-toast')].forEach(function (n) {
			if (n && n.parentNode !== host) host.appendChild(n);
		});
		placeDock();
	}
	['fullscreenchange', 'webkitfullscreenchange'].forEach(function (ev) {
		document.addEventListener(ev, function () { setTimeout(rehomeUI, 50); armIdleHide(); });
	});
	// fullscreen: controls fade after a few seconds without input
	/* One place decides when the controls go away. It used to be a single timer that gave up when
	 * something held the controls (a pause, a drag, a "hovered" bar) and was never started again:
	 * on a touch screen :hover sticks to the last thing tapped, and the bar swallowed the very events
	 * that would have re-armed the timer, so on a phone the controls stayed for good. Now the timer
	 * looks again until nothing holds them, hover only counts for a mouse, and every press inside the
	 * player (the bar included) restarts the countdown. */
	var idleTimer = null, lastPointer = 'mouse', lastPointerAt = 0;
	function playerIsFs() {
		var f = document.fullscreenElement || document.webkitFullscreenElement;
		return !!f && !!P.block && (f === P.block || f === P.box || (f.contains && f.contains(P.block)));
	}
	function playerFills() { return playerIsFs() || document.documentElement.classList.contains('cbx-land'); }
	function controlsHeld(b) {
		var scrub = $('#cbx-scrub', b), vol = $('#cbx-vol', b), bar = $('#cbx-bar', b), v = activeVideo(), fs = playerIsFs();
		if ((scrub && scrub._held) || (vol && vol._held) || xfLive) return 'drag';
		if (ddOpen && ddCtx && ddCtx.gear) return 'menu';
		if (v && v.paused && !fs) return 'paused';
		if (lastPointer === 'mouse' && !fs) { try { if (bar && bar.matches(':hover')) return 'hover'; } catch (e) {} }
		return '';
	}
	// Called for every pointer move inside the player (at the pointer's own rate, twice per event at that).
	// It used to cancel and start a timer each time. Now a call only moves the deadline; the one running
	// timer looks at it when it fires.
	var idleDue = 0, idleFireAt = 0;
	function armIdleHide(delay) {
		var b = P.block; if (!b) return;
		idleDue = Date.now() + (delay || 3000);
		if (b.classList.contains('cbx-idle')) { b.classList.remove('cbx-idle'); kickSmooth(); }
		else if (!P.smoothRaf) kickSmooth();
		// a pending timer that fires no later than the new deadline will do; only an earlier deadline (the
		// pointer left the player: hide sooner) needs a new one
		if (idleTimer && idleFireAt <= idleDue + 40) return;
		clearTimeout(idleTimer); idleFireAt = idleDue;
		var tick = function () {
			idleTimer = null;
			var blk = P.block; if (!blk) return;
			if (!(playerIsFs() && S.fsAutoHide) && !blk.classList.contains('cbx-overlay')) return;
			var left = idleDue - Date.now();
			if (left > 40) { idleFireAt = idleDue; idleTimer = setTimeout(tick, left); return; }
			if (controlsHeld(blk)) { idleFireAt = Date.now() + 1000; idleTimer = setTimeout(tick, 1000); return; }     // held: look again, never give up
			blk.classList.add('cbx-idle');
		};
		idleTimer = setTimeout(tick, delay || 3000);
	}
	function hideControlsNow() {
		var b = P.block; if (!b) return;
		if (controlsHeld(b)) { armIdleHide(); return; }
		idleDue = 0;
		b.classList.add('cbx-idle');
	}
	['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) {
		document.addEventListener(ev, function (e) {
			if (ev === 'pointerdown') { lastPointer = e.pointerType || 'mouse'; lastPointerAt = Date.now(); }
			else if (ev === 'touchstart') { lastPointer = 'touch'; lastPointerAt = Date.now(); }
			var b = P.block; if (!b) return;
			var inside = ev === 'keydown' || playerFills() || (!!P.box && !!e.target && P.box.contains && P.box.contains(e.target));
			if (!inside) return;
			// a press that finds the controls hidden only brings them back; remember that before they show
			if (ev === 'pointerdown' || ev === 'touchstart') { if (b.classList.contains('cbx-idle')) P.idleAtDown = Date.now(); }
			if (ev === 'pointermove' && e.pointerType && e.pointerType !== 'mouse') return;     // a finger dragging is not "activity" by itself
			armIdleHide();
		}, { capture: true, passive: true });
	});
	// playback that starts (a tap on the centre button, a stream that recovered) starts the countdown too
	document.addEventListener('playing', function (e) { if (e.isTrusted && P.block && e.target === activeVideo()) armIdleHide(); }, true);
	function toast(msg, ms) {
		var t = $('#cbx-toast');
		if (!t) { t = el('div', { id: 'cbx-toast' }); uiHost().appendChild(t); }
		t.textContent = msg;
		// a phone in a room: the bottom of the screen is the message box, the top of the player is free
		var top = '';
		try { if (!IS_MULTI && isPhone() && P.box && P.box.isConnected) { var pr = visibleRect(P.box, true); if (pr.height > 80) top = Math.round(pr.top + 10) + 'px'; } } catch (e) {}
		t.style.top = top; t.style.bottom = top ? 'auto' : '';
		t.classList.add('cbx-on');
		clearTimeout(toastTimer);
		toastTimer = setTimeout(function () { t.classList.remove('cbx-on'); }, ms || 2600);
	}

	/* ================================================================== *
	 * loop + navigation
	 * ================================================================== */

	var tick = null;
	// periodic UI work shares one timer; everything pauses while the tab is hidden
	var TICKS = [];
	function addTick(id, fn, every) { removeTick(id); TICKS.push({ id: id, fn: fn, every: every, last: 0 }); }
	function removeTick(id) { TICKS = TICKS.filter(function (t) { return t.id !== id; }); }
	var OWN_UI = '#cbx-block,#cbx-panel,#cbx-scrim,#cbx-dd,#cbx-toast,#cbx-launcher,#cbx-dock,#cbx-multidock,#cbx-hover,#cbx-watch,#cbx-hgrip,#cbx-duration,#cbx-bio,#cbx-stats';
	var CHAT_MSG = 'div[data-testid="chat-message"]';
	var CHAT_SEL = '[data-testid="chat-messages"],[data-testid="chat-list"],.chat-list,.msg-list-fvm,#ChatTabContents,.message-list';
	var chatTick = null;
	function scheduleChat() {
		clearTimeout(chatTick);
		chatTick = setTimeout(processChat, 250);
	}
	function whenIdle(fn, timeout) {
		if (window.requestIdleCallback) requestIdleCallback(function () { fn(); }, { timeout: timeout || 500 });
		else setTimeout(fn, 0);
	}
	function scheduleWork() {
		if (IS_MULTI) return;        // switched to the multi cam viewer in place: the page under it is only hidden
		clearTimeout(tick);
		tick = setTimeout(function () {
			if (IS_MULTI) return;
			whenIdle(decorateCards, 500);
			ensureRandomLink();
			autoAcceptRules();
			processChat();
			if (S.inlinePreview) $$('video:not([playsinline])').forEach(tagInline);
			playerTick();
			renderBioInfo();
			applyTransform();
			applyAudioChain(); sweepChains();
		}, 180);
	}

	var lastNavPath = location.pathname;
	function onNavigate() {
		if (recheckMulti('history')) return;
		if (IS_MULTI) return;
		// a state change on the same path (a query, a hash, an entry of ours) is not a new room: tearing
		// the player down for it only threw the rewind window away
		if (location.pathname === lastNavPath) { scheduleWork(); return; }
		lastNavPath = location.pathname;
		backStack = [];
		if (ddOpen) closeDD();
		if (recording()) { toast('Room changed — recording saved'); stopRecording(); }
		tipDone = false;
		tipStatus = 'not tried yet';
		bioFor = null;
		onPlayerNavigate();
		applySiteCSS();
		applyDark();
		installDuration();
		scheduleWork();
		qualityNote = 'not applied yet';
		setTimeout(function () { applyTipMute(0); }, 800);
		setTimeout(applyQuality, 2500);
	}

	function hookHistory() {
		['pushState', 'replaceState'].forEach(function (m) {
			var orig = history[m];
			history[m] = function () {
				var r = orig.apply(this, arguments);
				try { onNavigate(); } catch (e) {}
				return r;
			};
		});
		window.addEventListener('popstate', onNavigate);
	}

	/* ================================================================== *
	 * boot
	 * ================================================================== */

	if (window.top !== window.self && !IS_MULTI) return;

	try { console.log('[cep] Chaturbate Enhanced Plus ' + VERSION + ' loaded on ' + location.pathname); } catch (e) {}

	installInlinePreview();
	installSoundBlock();
	installSiteDvr();
	installBgMute();
	applyDark();
	applySiteCSS();

	// every way an address can change without a new document asks the multi cam question again
	['hashchange', 'pageshow', 'load'].forEach(function (ev) { window.addEventListener(ev, function () { try { recheckMulti(ev); } catch (e) {} }); });
	try { if (window.navigation && navigation.addEventListener) navigation.addEventListener('currententrychange', function () { setTimeout(function () { try { recheckMulti('navigation'); } catch (e) {} }, 0); }); } catch (e) {}

	onReady(function () {
		// the address may have gained the flag between the first line of the script and now
		if (!IS_MULTI && !WATCH_POP && isHomePath() && urlSaysMulti()) {
			log('multi: flag found at DOM ready, not at start');
			IS_MULTI = true;
			try { window.name = MULTI_NAME; } catch (e) {}
			try { sessionStorage.setItem(MULTI_MARK, '1'); } catch (e) {}
		}
		if (IS_MULTI) { buildMulti(); return; }
		if (WATCH_POP) { document.head.appendChild(el('style', null, UI_CSS)); document.title = WATCH_POP; openWatchOnly(WATCH_POP, true); return; }
		applyTouchClass();
		applySiteCSS();
		buildUI();
		installHoverPreview();
		hookHistory();
		installDuration();
		decorateCards();
		renderBioInfo();
		installExclusiveAudio();
		installErrorQuality();
		installInactiveLoad();
		startAlerts();
		startQualityWatchdog();
		setTimeout(applyQuality, 2500);
		playerTick();
		applyTipMute(0);
		addTick('bar', function () { updateBar(); captureThumb(); watchSiteRebuild(); refreshSiteDvrNote(); }, 1000);
		addTick('heartbeat', siteHeartbeat, 2000);
		// changes that are not DOM insertions (the site player getting its source, say) were only ever
		// noticed because the observer loop ran a full pass five times a second; a pass every 3 s does that job
		addTick('work', function () { if (!recheckMulti('tick')) scheduleWork(); }, 3000);
		watchRotation();
		(function pump() {
			var scrub = $('#cbx-scrub'), held = scrub && scrub._held, now = Date.now();
			if (!document.hidden && !IS_MULTI) {
				for (var i = 0; i < TICKS.length; i++) {
					var t = TICKS[i];
					if (held && t.id === 'bar' ? true : now - t.last >= t.every - 20) { t.last = now; try { t.fn(); } catch (e) { log('tick', t.id, e && e.message); } }
				}
			}
			setTimeout(pump, held ? 250 : 1000);
		})();
		installKeys();
		watchEnvSaver();
		var rsz = null; window.addEventListener('resize', function () { clearTimeout(rsz); rsz = setTimeout(function () { applyTouchClass(); applyTransform(); fitBar(); }, 300); });
		// warm hls.js once the page settles so the first hover preview does not wait for it
		if (S.hoverPreview && canUseMse()) whenIdle(function () { loadHlsJs().catch(function () {}); }, 4000);
		new MutationObserver(function (muts) {
			if (IS_MULTI) return;
			// chat rooms mutate several times a second; only new messages come from
			// there, so keep the grid/player work for changes outside the chat
			var chatOnly = true, ownOnly = true;
			for (var i = 0; i < muts.length && chatOnly; i++) {
				var m = muts[i];
				// our own bar, labels and menus change several times a second. They used to count as page
				// changes, so every timeline repaint scheduled a full pass (cards, chat, player, bio,
				// transform, audio), and that pass repainted the timeline: a loop at five passes a second
				// for as long as a room was open. On a phone that is what a drag had to compete with.
				if (m.target.nodeType === 1 && m.target.closest(OWN_UI)) continue;
				ownOnly = false;
				if (m.target.nodeType === 1 && m.target.closest(CHAT_SEL)) continue;
				for (var j = 0; j < m.addedNodes.length; j++) {
					var n = m.addedNodes[j];
					if (n.nodeType !== 1) continue;
					if (n.matches(CHAT_MSG) || n.closest(CHAT_SEL)) continue;
					chatOnly = false; break;
				}
				if (m.addedNodes.length === 0 && m.target.nodeType === 1 && !m.target.closest(CHAT_SEL)) {
					// removals outside chat (e.g. cards, player) still matter
					chatOnly = false;
				}
			}
			if (ownOnly) return;
			if (chatOnly) scheduleChat(); else scheduleWork();
		}).observe(document.body, { childList: true, subtree: true });
	});
})();