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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Chaturbate Enhanced Plus
// @namespace    chaturbate.enhanced.plus
// @version      2.0.3
// @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,
		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,
		recAudioOnly: false,
		recVideoOnly: false,
		recLowPerf: false,
		recWarnLeave: 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.0.3'; } })();
	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) {} }

	function log() {
		if (!S.debug) return;
		try { console.log.apply(console, ['[cep]'].concat([].slice.call(arguments))); } catch (e) {}
	}

	// the multi cam page is this same origin with a flag: the phone site (m.chaturbate.com) redirects
	// chaturbate.com links to itself and drops the query on the way, so the flag rides in the hash too
	var IS_MULTI = /[?&]cbx-multi=1/.test(location.search) || /(^|[#&])cbx-multi(=1)?(&|$)/.test(location.hash);
	function multiUrl() { return location.origin + '/?cbx-multi=1#cbx-multi'; }
	var WATCH_POP = (location.search.match(/[?&]cbx-watch=([a-z0-9_]+)/i) || [])[1] || '';

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


	/* ================================================================== *
	 * 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 () {
			applyBgMute(document.hidden);
			applyInactive(document.hidden);
		});
		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;
		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) {
		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;
	}

	function installSiteDvr() {
		var MS = window.MediaSource, MMS = window.ManagedMediaSource, SB = window.SourceBuffer;
		if (siteDvr.hooked || !SB || !SB.prototype.remove || !(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; } catch (e) {}
			return u;
		};
		[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');

	// 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 r = bufferedRanges(v);
		if (!r.length) return t;
		var best = t, dist = Infinity;
		for (var i = 0; i < r.length; i++) {
			if (t >= r[i][0] && t <= r[i][1]) return t;
			var edge = t < r[i][0] ? r[i][0] + 0.1 : r[i][1] - 0.3, d = Math.abs(edge - t);
			if (d < dist) { dist = d; best = edge; }
		}
		return best;
	}
	function nextBufferedStart(v, now) {
		var r = bufferedRanges(v);
		for (var i = 0; i < r.length; i++) if (r[i][0] > now + 0.2) return r[i][0];
		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 (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++;
							var frames = String(new Error().stack || '').split('\n').slice(2, 4).map(function (l) { return l.trim(); }).join(' < ');
							// a full stack the first time each caller shows up; one line after that
							if (frames !== siteDvr.lastDrop) { siteDvr.lastDrop = frames; log('site seek to ' + want.toFixed(1) + ' dropped while rewound at ' + now.toFixed(1) + '\n' + new Error().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 (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) 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 = /^blob:/.test(src) ? 'MSE' : (src ? 'native HLS (nothing to hook' + (autoDeep() ? ' — deep rewind 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' : '')
		];
		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 };
	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() { return isTouch() && innerWidth < 900; }
	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;
	}

	/* ---- 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();
			});
		};
		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) video.currentTime = h.liveSyncPosition; else if (w) video.currentTime = w.end - 0.5; };
		E.setBehind = function (sec) { if (E.h) E.h.config.backBufferLength = sec; };
		E.destroy = function () { try { if (E.h) E.h.destroy(); } catch (e) {} E.h = 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;
		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() { var v = activeVideo(); if (!v) return; if (v.paused) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } else v.pause(); updateBar(); }
	function toggleMute() { var v = activeVideo(); if (!v) return; userHasInteracted = true; v.muted = !v.muted; if (!v.muted && v.volume === 0) v.volume = 0.5; updateBar(); }
	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
	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) {
			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 = 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();
			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; }
		try { if (document.pictureInPictureElement) document.exitPictureInPicture(); else if (v.requestPictureInPicture) v.requestPictureInPicture(); else toast('No picture in picture here'); }
		catch (e) { toast('Picture in picture was refused'); }
	}
	function goFullscreen() {
		var target = P.box || (activeVideo() && activeVideo().closest(MAIN_PLAYER_SEL)) || activeVideo();
		if (!target) return;
		try {
			if (document.fullscreenElement) document.exitFullscreen();
			else if (target.requestFullscreen) { var pr = target.requestFullscreen(); if (pr && pr.then && isPhone() && S.rotateFullscreen && screen.orientation && screen.orientation.lock) pr.then(function () { screen.orientation.lock('landscape').catch(function () {}); }, function () {}); }
			else if (P.video && P.video.webkitEnterFullscreen) P.video.webkitEnterFullscreen();
		} catch (e) {}
	}
	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.
		var siteMode = siteDvrActive();
		block.classList.add('cbx-overlay');
		if (siteMode) block.classList.add('cbx-site');
		if (getComputedStyle(P.box).position === 'static') P.box.style.position = 'relative';
		P.box.appendChild(block);
		P.block = block; P.shell = shell; P.bar = bar;
		document.documentElement.classList.add('cep-block');
		ensureStripCSS();
		wireBar(bar, shell);
		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"><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); });
		zin.addEventListener('change', function (e) { if (zoomSettled(e)) rememberZoom(); });
		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(block);
		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));
		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
	function wireOverlay(block) {
		var box = P.box;
		['pointermove', 'pointerdown', 'touchstart'].forEach(function (ev) { box.addEventListener(ev, function () { if (P.block === block) armIdleHide(); }, { passive: true }); });
		box.addEventListener('pointerleave', function () { if (P.block === block) 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)
	function wirePan(shell) {
		var x0 = 0, y0 = 0, t0 = 0, px0 = 0, py0 = 0, mode = null, hold = null, held2x = false;
		var SWIPE_SEC = 90; // a full-width swipe moves this many seconds
		var zoomed = panActive;
		shell.addEventListener('pointerdown', function (e) {
			if (e.pointerType === 'mouse' && (e.button !== 0 || !zoomed())) return;
			x0 = e.clientX; y0 = e.clientY; mode = 'wait'; held2x = false; px0 = xform.panX || 0; py0 = xform.panY || 0;
			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 (!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'; }
				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') {
				xform.panX = px0 + dx; xform.panY = py0 + dy; applyTransform();
				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 () {
			// 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 (!mode) return;
			clearTimeout(hold);
			if (held2x) { var v = activeVideo(); if (v) setRate(v, 1); held2x = false; }
			// 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;
		}); });
	}
	function removeBlock() {
		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');
	}
	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; v.muted = 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;
			single = setTimeout(function () {
				single = null;
				// controls hidden: the first tap brings them back and does nothing else
				if (P.block && P.block.classList.contains('cbx-idle')) { armIdleHide(); return; }
				if (!siteFlavor()) { togglePause(); return; }
				// 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 = ''; }
				if (under && under.tagName !== 'VIDEO' && under.closest && under.closest(MAIN_PLAYER_SEL) && !under.closest('#cbx-block')) { try { under.click(); } catch (err) {} 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,
		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(), w = seekWindow(v);
			if (v && w && !v.paused) paintScrub(bar._refs, v, w, null);
		}
		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, at = R.at;
		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; }
		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);
	}

	/* ---- deep rewind ---- */
	function installDvr() {
		var user = roomName();
		if (!wantDeep() || P.suspended || P.forcedSite || !user || P.busy) return;
		if (P.video && P.video.isConnected) 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) {
			log('dvr', e && (e.message || e)); toast('Deep rewind could not load: ' + (e && e.message || e), 5000);
			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) { var p = v.play(); if (p && p.catch) p.catch(function () {}); } });
			set('pause', function () { if (v === P.video) 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;
		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; }
			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) { var rp = v.play(); if (rp && rp.catch) rp.catch(function () {}); }
			var edgeSick = (v._cbxTimeouts || 0) >= 2 && !v.buffered.length, siteDead = !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) {
		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() {
		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 = []; P.sheet = null; clearLoop();
		if (E) E.destroy();
		if (v) { try { v.remove(); } catch (e) {} }
		document.documentElement.classList.remove('cep-dvr-on');
		var site = P.site && P.site.isConnected ? P.site : siteVideo();
		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() { dropDvr(); 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; removeDvr(); xform.zoom = roomZoom(); xform.panX = xform.panY = 0; }

	/* ---- 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() { return Math.max(120, Math.round((P.box ? P.box.clientWidth : innerWidth) * 9 / 16)); }
	function installPhoneLayout() {
		if (!isPhone() || !P.box || !P.box.isConnected) return;
		P.box.setAttribute('data-cbx-box', '');
		if (P.hgrip) return;
		var below = P.box.nextElementSibling, parent = P.box.parentElement;
		if (!below || !parent || getComputedStyle(below).position !== 'absolute') return;
		if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative';
		below.setAttribute('data-cbx-below', '');
		var grip = el('div', { id: 'cbx-hgrip', title: 'Drag to change the picture height (double-tap to reset)' }, '<i></i>');
		parent.appendChild(grip); P.hgrip = grip; P.hgripBelow = below;
		var y0 = 0, h0 = 0, taps = 0;
		grip.addEventListener('pointerdown', function (e) {
			e.preventDefault(); e.stopPropagation(); y0 = e.clientY; h0 = S.phoneHeight || phoneNaturalHeight();
			grip.classList.add('cbx-dragging'); try { grip.setPointerCapture(e.pointerId); } catch (err) {}
		});
		grip.addEventListener('pointermove', function (e) {
			if (!grip.classList.contains('cbx-dragging')) return;
			var h = Math.round(Math.max(120, Math.min(innerHeight - 240, h0 + (e.clientY - y0))));
			S.phoneHeight = Math.abs(h - phoneNaturalHeight()) < 8 ? 0 : h; applyPhoneHeight();
		});
		['pointerup', 'pointercancel'].forEach(function (ev) { grip.addEventListener(ev, function () {
			if (!grip.classList.contains('cbx-dragging')) return;
			grip.classList.remove('cbx-dragging'); save();
			taps++; setTimeout(function () { taps = 0; }, 350);
			if (taps === 2) { S.phoneHeight = 0; save(); applyPhoneHeight(); toast('Picture height reset'); }
		}); });
		applyPhoneHeight();
	}
	function applyPhoneHeight() {
		var portrait = matchMedia('(orientation: portrait)').matches, on = !!P.hgrip && P.hgrip.isConnected && portrait;
		var root = document.documentElement;
		root.classList.toggle('cbx-ph', on);
		if (on) root.style.setProperty('--cbx-ph', (S.phoneHeight || phoneNaturalHeight()) + 'px'); else root.style.removeProperty('--cbx-ph');
		if (P.hgrip) P.hgrip.style.display = on ? '' : 'none';
		applyTransform();
	}
	function removePhoneLayout() {
		if (P.hgrip) { try { P.hgrip.remove(); } catch (e) {} P.hgrip = null; }
		if (P.hgripBelow) { P.hgripBelow.removeAttribute('data-cbx-below'); P.hgripBelow = null; }
		if (P.box) P.box.removeAttribute('data-cbx-box');
		document.documentElement.classList.remove('cbx-ph', 'cbx-land');
		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) {
		var root = document.documentElement;
		if (on) {
			var t = P.box;
			if (t && t.requestFullscreen && !document.fullscreenElement) {
				var pr = t.requestFullscreen();
				if (pr && pr.then) pr.then(function () { P.autoFs = true; }, function () { root.classList.add('cbx-land'); });
				else root.classList.add('cbx-land');
			} else root.classList.add('cbx-land');
		} else {
			root.classList.remove('cbx-land');
			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(); }
		ensureBlock();
		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;
		return !!a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName));
	}

	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 win = window.open('https://chaturbate.com/?cbx-watch=' + encodeURIComponent(user), 'cbx-theatre-' + user, 'popup=1,width=' + w + ',height=' + h);
		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();

		var box = el('div', { id: 'cbx-watch' },
			'<video playsinline webkit-playsinline autoplay controls></video>' +
			'<header><b>' + esc(user) + '</b><span class="cbx-note">Stream only — chat is not connected</span>' +
			'<button id="cbx-watch-close" aria-label="Close">&times;</button></header>');
		document.body.appendChild(box);
		document.documentElement.style.overflow = 'hidden';

		$$('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) {} } }); }

		hlsFor(user).then(function (url) {
			if (!url) { toast(user + ' is offline'); return; }
			// long back buffer here, because this player is ours
			return attachStream(v, url, { backBufferLength: 600, liveDurationInfinity: true }).then(function () {
				var p = v.play(); if (p && p.catch) p.catch(function () {});
			});
		}).catch(function () { toast('Could not load the stream'); });
	}

	function closeWatchOnly() {
		var box = $('#cbx-watch');
		if (!box) return;
		var v = $('video', box);
		try { if (v && v._cbxHls) v._cbxHls.destroy(); } catch (e) {}
		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) { 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) { 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;

	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) { 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 (trBusy || !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 (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) {
			trFails++;
			log('translate failed', e);
			if (trFails >= 5) {
				S.translateChat = false; save(); refreshPanel();
				toast('Translation service unreachable — turned off');
			}
		}).then(function () {
			trBusy = false;
			setTimeout(pumpQueue, 220);
		});
	}

	// 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;
		$$('div[data-testid="chat-message"]').forEach(function (m) {
			if (m._cbxTr || m.classList.contains('cbx-muted')) return;
			m._cbxTr = true;
			var body = m.querySelector('.message,[data-testid="chat-message-text"]') || m;
			var text = (body.textContent || '').trim();
			if (!text || text.length > 400) return;
			if (trQueue.length > 40) return;
			trQueue.push({ node: m, text: text });
		});
		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}' +
		'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.cbx-site,#cbx-block.cbx-site #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-block.cbx-paused #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-box]{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}' +
		'html.cbx-ph #cbx-block.cbx-cover #cbx-shell video.cbx-dvr{object-fit:cover}' +
		'#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}#cbx-hgrip.cbx-dragging i{background:#f67300}' +
		/* rotated: the box fills the viewport when real fullscreen was not allowed */
		'html.cbx-land [data-cbx-box]{position:fixed!important;inset:0!important;width:100%!important;height:100%!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:20px;height:100%;margin:0;accent-color:#f67300;cursor:pointer;touch-action:none}' +
		'#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 :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}' +
		/* small screens: two rows, bigger targets */
		'@media (max-width:699.98px){#cbx-bar{padding:1px 4px 3px}#cbx-behind,#cbx-bar .cbx-qbtn{display:none}' +
		'.cbx-volwrap.cbx-open #cbx-vol,#cbx-vol.cbx-held{width:56px;margin:0 8px 0 4px}' +
		'.cbx-row-btns{flex-wrap:nowrap;justify-content:space-between;gap:2px}' +
		'#cbx-bar button{min-width:0;padding:0;flex:0 0 auto;width:38px;min-height:44px}#cbx-bar button b{display:none}' +
		'#cbx-bar .cbx-live{width:auto;padding:0 6px;margin:0 1px;font-size:10px}' +
		'}@media (max-width:379.98px){#cbx-bar button[data-act="back"],#cbx-bar button[data-act="fwd"]{display:none}}' +
		'html.cbx-touch #cbx-bar button{min-height:34px}' +
		'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}' +
		'#cbx-watch{position:fixed;inset:0;z-index:2147481500;background:#0f1215;display:flex;flex-direction:column}' +
		'#cbx-watch header{display:flex;align-items:center;gap:10px;padding:10px 12px;color:#e8ebed;font:14px/1.3 system-ui,sans-serif;border-bottom:1px solid #2a3138}' +
		'#cbx-watch header .cbx-note{flex:1;color:#8b969e;font-size:12px;margin:0}' +
		'#cbx-watch header button{width:30px;height:30px;border:0;border-radius:15px;background:#1c2126;color:#e8ebed;font-size:18px;line-height:1;cursor:pointer}' +
		'#cbx-watch video{flex:1;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
	 * ================================================================== */

	function hlsFor(user) {
		return fetch('/api/chatvideocontext/' + encodeURIComponent(user) + '/', { credentials: 'include', cache: 'no-store' })
			.then(function (r) { return r.json(); })
			.then(function (d) { return d && d.hls_source ? d.hls_source : null; });
	}

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

	function loadHlsJs() {
		var have = requiredHls();
		return have ? Promise.resolve(have) : Promise.reject(new Error('hls.js was not loaded by the userscript manager'));
	}

	// 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 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 (video.classList.contains('cbx-dvr')) 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 || '') && video.classList.contains('cbx-dvr')) video._cbxTimeouts = (video._cbxTimeouts || 0) + 1;
					if (!d.fatal) return;
					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) {
						try { h.recoverMediaError(); } catch (e) {}
					} 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);
				h.attachMedia(video);
				h._reusable = reusable;
				video._cbxHls = h;
				log('attached via hls.js');
			}).catch(function (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>',
		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() {
		document.head.appendChild(el('style', null, UI_CSS));
		document.head.appendChild(el('style', null, MULTI_CSS));
		var root = el('div', { id: 'cbx-multi' },
			'<header id="cbx-multi-bar">' +
			'<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>' +
			'</header><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.style.overflow = 'hidden';
		document.title = 'Multi cam';

		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) { var pp = video.play(); if (pp && pp.catch) pp.catch(function () {}); } else video.pause();
			});
			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('https://chaturbate.com/' + 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) { 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;

			hlsFor(user).then(function (url) {
				if (!url) {
					var st0 = cell.getAttribute('data-status');
					state.textContent = st0 && st0 !== 'public' && st0 !== 'offline' ? st0.charAt(0).toUpperCase() + st0.slice(1) + ' show' : 'Offline';
					cell.setAttribute('data-offline', '1');
					if (S.multiAutoRemove) {
						cell.remove();
						jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; }));
						sync();
					} else if (S.multiHideOffline) cell.style.display = 'none';
					return;
				}
				return attachStream(video, url, { backBufferLength: 120 }).then(function () {
					state.style.display = 'none';
					var p = video.play(); if (p && p.catch) p.catch(function () {});
				});
			}).catch(function (e) { state.textContent = 'Could not load'; log('multi', user, e); });

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

		$('#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'); });
			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);
			$('.cbx-big-x', bigEl).addEventListener('click', 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) { e.preventDefault(); closeBig(); });
			var p = video.play(); if (p && p.catch) p.catch(function () {});
		}
		function closeBig() {
			if (!bigEl) return;
			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.exitFullscreen(); else root.requestFullscreen(); } catch (e) {}
		}
		$('#cbx-multi-fs', root).addEventListener('click', toggleFs);
		// 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';
			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=([^&]+)/);
		if (m) decodeURIComponent(m[1]).split(',').forEach(addCam);
		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 === '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
	function installInactiveLoad() {
		if (!S.inactiveLoad || !document.hidden || !roomName()) return;
		var tries = 0, t = setInterval(function () {
			if (++tries > 60) { clearInterval(t); return; }
			// the site parks a "click to play" image over background-tab rooms
			var btn = $('img[src*="play-inactive"]');
			if (btn) { clickHard(btn.closest('button,a,div') || btn); log('inactive: clicked the site play button'); }
			var v = siteVideo();
			if (!v) return;
			if (!v.paused && v.readyState >= 3) { clearInterval(t); return; }
			if (!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);
	}

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

	// how far the zoomed picture may be dragged before its edge would show
	function panLimits() {
		var sh = P.shell, va = videoAspect(), z = xform.zoom;
		if (!sh || !va) return { x: 0, y: 0 };
		var W = sh.clientWidth || 1, H = sh.clientHeight || 1, picW, picH;
		// no early exit at zoom <= 1: a cover-fit picture overhangs the box sideways at zoom 1, and
		// that overhang is exactly what the phone pan drags through (the max(0, …) below covers the
		// contain case on its own)
		var cover = coverCrops();
		if ((H / W > va) !== cover) { picW = W; picH = W * va; } else { picH = H; picW = H / va; }
		return { x: Math.max(0, (picW * z - W) / 2), y: Math.max(0, (picH * z - H) / 2) };
	}
	function clampPan() {
		var L = panLimits();
		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));
	}
	// on phones a taller box shows the picture cover-fit: it is cropped sideways, so it can pan too
	function coverCrops() {
		if (!P.shell || !document.documentElement.classList.contains('cbx-ph')) return false;
		var va = videoAspect(), W = P.shell.clientWidth, H = P.shell.clientHeight;
		return W > 0 && H / W > va + 0.01;
	}
	function panActive() { return xform.zoom > 1.02 || coverCrops(); }
	function applyTransform() {
		var v = activeVideo();
		if (!v) return;
		clampPan();
		var pan = panActive() && (xform.panX || xform.panY) ? 'translate(' + Math.round(xform.panX) + 'px,' + Math.round(xform.panY) + 'px) ' : '';
		var t = pictureTouched() || pan ? pan + 'rotate(' + xform.rot + 'deg) scaleX(' + xform.flip + ') scale(' + xform.zoom + ')' : '';
		if (v.style.transform !== t) v.style.transform = t;
		if (t) v.style.transformOrigin = 'center center';
		if (P.block) {
			var cc = coverCrops();
			// cover-fit only when the box is taller than the picture (the sideways crop the pan handles);
			// a box wider than the picture keeps contain-fit, otherwise a portrait or 4:3 stream lost its
			// top and bottom for good and "Fit" still showed a cropped picture
			P.block.classList.toggle('cbx-cover', cc);
			P.block.classList.toggle('cbx-zoomed', xform.zoom > 1.02);
			P.block.classList.toggle('cbx-panx', xform.zoom <= 1.02 && cc);
		}
		var f = filterCSS();
		if (v.style.filter !== f) v.style.filter = f;
		syncBar();
	}
	// the scale at which the picture just covers the box (letterbox gone) for this stream's aspect
	function coverScale() {
		var sh = P.shell, va = videoAspect(); if (!sh || !va) return 1;
		var ba = sh.clientHeight / Math.max(1, sh.clientWidth); if (!ba) return 1;
		return Math.max(ba / va, va / ba);
	}
	// the zoom the viewer sees, relative to the whole frame: a cover-fit box already counts as Fill
	function coverFactor() { return coverCrops() ? coverScale() : 1; }
	function effZoom() { return xform.zoom * coverFactor(); }
	function setEffZoom(z) { xform.zoom = Math.max(0.5, Math.min(3, z)) / coverFactor(); 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() {
		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();

	function audioChain(v) {
		if (!v) return null;
		if (chains.has(v)) return chains.get(v);
		var Ctx = window.AudioContext || window.webkitAudioContext;
		if (!Ctx) return null;
		// a cross-origin src that is not MSE is tainted: routing it would give silence
		if (!/^blob:/.test(v.currentSrc || v.src || '') && !v.srcObject) return null;
		try {
			var ctx = new Ctx();
			var src = ctx.createMediaElementSource(v);
			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); pres.connect(gain);
			gain.connect(ctx.destination); gain.connect(dest);
			var ch = { ctx: ctx, gain: gain, hp: hp, mid: mid, pres: pres, dest: dest };
			chains.set(v, ch);
			return ch;
		} catch (e) {
			log('audio chain refused', e && e.message);
			chains.set(v, null);
			return null;
		}
	}

	function applyAudioChain() {
		var v = activeVideo();
		if (!v) return;
		var wanted = S.volumeBoost !== 100 || S.voiceBoost;
		if (!wanted && !chains.has(v)) 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 () {}); }
		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', syncStrip, 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;
	}

	/* ================================================================== *
	 * 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-sheet{left:0;right:0;top:auto;bottom:0;width:100%;max-width:none;max-height:65vh;border-radius:14px 14px 0 0;border-width:1px 0 0;padding-bottom:calc(14px + env(safe-area-inset-bottom,0px))}',
		'@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' + (S.volumeBoost !== 100 || S.voiceBoost ? '<small>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>';
		}
		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.') +
			'<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="snap">Save a frame<small>S</small></button>' +
			'<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: a bottom sheet, placed by the stylesheet
		if (isTouch()) { ddEl.style.left = ''; ddEl.style.top = ''; ddEl.classList.add('cbx-sheet'); return; }
		ddEl.classList.remove('cbx-sheet');
		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');
		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(); boostNote(); }
			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') { applyAudioChain(); boostNote(); }
			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 what = doBtn.getAttribute('data-do');
				e.preventDefault(); e.stopPropagation();
				closeDD();
				if (what === 'bmprev') jumpBookmark(-1); else if (what === 'bmnext') jumpBookmark(1); else runAct(what);
				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 boostNote() {
		var n = $('#cbx-boost-note', ddEl);
		if (!n) return;
		var v = activeVideo();
		var wanted = S.volumeBoost !== 100 || S.voiceBoost;
		if (!wanted) { n.textContent = ''; return; }
		var ch = v && chains.get(v);
		n.textContent = ch ? '' : (v ? 'Not available for this player: the stream is not routed through the page.' : 'Starts when the video plays.');
	}

	// 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() || S.volumeBoost !== 100 || !!S.voiceBoost);
		}
		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; }
		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) { boostNote(); 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{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)}',
		'@media (prefers-reduced-motion:reduce){#cbx-panel,#cbx-scrim,#cbx-toast,.cbx-sw,.cbx-sw::after{transition:none}}'
	].join('');

	var MULTI_CSS = [
		'#cbx-multi{position:fixed;inset:0;z-index:2147483000;background:#0f1215;color:#e8ebed;overflow:auto;padding:10px;',
		'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;inset:0;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:12px;right:12px;top:10px;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}',
		'.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.'],
				'<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.']
			] },
			{ title: 'Extras', items: [
				['mediaSession', 'Lock screen and headset controls', null],
				['showDuration', 'Show stream time', null],
				['pipButton', 'Picture in picture controls', null]
			] }
		] },
		{ 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]
			] },
			{ 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).']
			] }
		] },
		{ id: 'sound', label: 'Sound', groups: [
			{ title: 'Effects', 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 in the Sound menu.']
			] }
		] },
		{ 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.'],
				['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],
				['edgeSwipe', 'Open by swiping from the right edge', null]
			] },
			{ title: 'Advanced', items: [
				['debug', 'Log to console', null]
			] }
		] }
	];

	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(); window.open(multiUrl(), '_blank'); });
		document.body.appendChild(multiDockEl);
		placeLauncher();
		makeDraggable(launcherEl);
		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-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) {
			if (e.target.id === 'cbx-filter') applyFilter(e.target.value);
		});
		$('#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-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 () {
			window.open(multiUrl(), '_blank');
		});

		$('#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 SEL_KEYS = { '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); }); });
			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 (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 === '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();
	}

	var durTimer = null;

	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');

		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');
			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 dn = $('#cbx-dvr-note', panelEl);
		if (dn) {
			var ring = P.ring;
			dn.textContent = (P.video ? P.heldNote : 'deep rewind not running') +
				(ring ? ' · ' + Math.round(ring.bytes / 1048576) + ' MB kept for saving' : '');
		}

		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, {})
		};
		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();
		setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
		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);
				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);
	}

	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;
		function down(e) {
			var p = e.touches ? e.touches[0] : e;
			dragging = true; moved = false;
			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;
			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 });
			} else openPanel(!panelEl.classList.contains('cbx-on'));
		}
		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
	var idleTimer = null;
	function armIdleHide(delay) {
		clearTimeout(idleTimer);
		var b = P.block; if (!b) return;
		var overlay = b.classList.contains('cbx-overlay'), fsEl = document.fullscreenElement || document.webkitFullscreenElement;
		var fs = fsEl === b || (!!fsEl && fsEl === P.box);
		kickSmooth();
		b.classList.remove('cbx-idle');
		if (!(fs && S.fsAutoHide) && !overlay) return;
		idleTimer = setTimeout(function () {
			if (P.block !== b) return;
			var scrub = $('#cbx-scrub', b), bar = $('#cbx-bar', b), v = activeVideo();
			if (scrub && scrub._held) return;
			if (overlay && !fs) {
				if (v && v.paused) return;
				try { if (bar && bar.matches(':hover')) return; } catch (e) {}
			}
			b.classList.add('cbx-idle');
		}, delay || 3000);
	}
	['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) {
		document.addEventListener(ev, function () {
			if (!P.block) return;
			if (document.fullscreenElement === P.block || document.webkitFullscreenElement === P.block || ev === 'keydown') armIdleHide();
		}, { capture: true, passive: true });
	});
	function toast(msg, ms) {
		var t = $('#cbx-toast');
		if (!t) { t = el('div', { id: 'cbx-toast' }); uiHost().appendChild(t); }
		t.textContent = msg;
		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 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() {
		clearTimeout(tick);
		tick = setTimeout(function () {
			whenIdle(decorateCards, 500);
			ensureRandomLink();
			autoAcceptRules();
			processChat();
			if (S.inlinePreview) $$('video:not([playsinline])').forEach(tagInline);
			playerTick();
			renderBioInfo();
			applyTransform();
			applyAudioChain();
		}, 180);
	}

	function onNavigate() {
		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();

	onReady(function () {
		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);
		watchRotation();
		(function pump() {
			var scrub = $('#cbx-scrub'), held = scrub && scrub._held, now = Date.now();
			if (!document.hidden) {
				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(); }, 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) {
			// 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;
			for (var i = 0; i < muts.length && chatOnly; i++) {
				var m = muts[i];
				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 (chatOnly) scheduleChat(); else scheduleWork();
		}).observe(document.body, { childList: true, subtree: true });
	});
})();