☰

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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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