Chaturbate Enhanced Plus

Not affiliated with Chaturbate. Rewind player with minutes of scrubbable buffer, a toolbar under the player with zoom, picture filters, volume and voice boost, recording and room actions, hover previews, hide cams by name or country, multi cam viewer, chat translate, room notes and live alerts, dark theme and site cleanup.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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

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

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

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Chaturbate Enhanced Plus
// @namespace    chaturbate.enhanced.plus
// @version      1.0.1
// @description  Not affiliated with Chaturbate. Rewind player with minutes of scrubbable buffer, a toolbar under the player with zoom, picture filters, volume and voice boost, recording and room actions, hover previews, hide cams by name or country, multi cam viewer, chat translate, room notes and live alerts, dark theme and site cleanup.
// @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 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,
		deepPlayer: true,
		playerMode: 'deep',
		rewindBar: true,
		dvrMode: true,
		dvrBuffer: 300,
		dvrQuality: 720,
		mobileFullQuality: false,
		mobileHeightByRoom: {},
		mobileDefaultH: 'fit',
		swipeSeek: true,
		holdFast: true,
		showHealth: false,
		tipMarks: true,
		mediaSession: true,
		fsAutoHide: true,
		statsOverlay: false,
		scrubThumbs: true,
		dataSaver: false,
		dataSaverAuto: true,
		chatMuted: [],
		chatKeywords: '',
		chatTipsOnly: false,
		chatFont: 0,
		bigBuffer: false,
		parkSite: true,
		mobileHeight: 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,
		biggerCards: false,
		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,
		multiResizable: 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,
		showStrip: 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 '1.0.1'; } })();
	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;
			// 1.9.9: the built-in bar became the player; browser controls are opt-in now
			if (gen < 3) { out.bioInfo = false; }
			// 0.9.6: one switch — deep rewind player on, or the plain site player
			if (gen < 4) {
				if (typeof raw.rewindBar === 'boolean' || typeof raw.dvrMode === 'boolean') out.deepPlayer = raw.rewindBar !== false && raw.dvrMode !== false;
				out.cardWatchBtn = true;
				out.openNewTab = true;
			} else if (typeof raw.deepPlayer !== 'boolean' && typeof raw.playerMode === 'string') {
				out.deepPlayer = raw.playerMode !== 'off';
			}
			// 1.0.1: heights dragged with the old, mis-measured default were often tiny; start over
			if (gen < 5) { out.mobileHeight = 0; out.mobileHeightByRoom = {}; }
			out.uiGen = 5;
		} catch (e) {}
		out.playerMode = out.deepPlayer ? 'deep' : 'off';
		out.rewindBar = out.deepPlayer;
		out.dvrMode = out.deepPlayer;
		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) {}
	}

	var IS_MULTI = /[?&]cbx-multi=1/.test(location.search);

	/* ================================================================== *
	 * 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);
				} 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;
	}
	function setTipVolumeZero(slider) { return setTipVolume(slider, 0); }

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


	/* ================================================================== *
	 * 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 };
	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() ? (S.bigBuffer ? 150 : 60) : (S.bigBuffer ? 400 : 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.dvrBuffer, 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: 10,
				maxLiveSyncPlaybackRate: 1.05, 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.dvrQuality || S.qualityCap || 0;
		if (isPhone() && !S.mobileFullQuality) want = want ? Math.min(want, 480) : 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.dvrBuffer, Math.floor(Math.max(0, budget - fwd) / (bps / 8))));
			P.eng.setBehind(use); P.heldSec = use;
			if (use < S.dvrBuffer) {
				// 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.dvrBuffer; })[0];
				P.heldNote = 'holds ~' + fmtTime(use) + ' at ' + (t.label || t.height + 'p') + ' (asked for ' + fmtTime(S.dvrBuffer) + ')' +
					(fits ? ' — ' + (fits.label || fits.height + 'p') + ' would hold the full ' + fmtTime(S.dvrBuffer) : ' — 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;
		if (Math.abs(target - before) < 0.3) { if (!quiet) toast(target <= before ? 'Already at the start of the buffer' : 'Already live'); return false; }
		try { v.currentTime = target; } catch (e) { toast('This player refused the seek'); return false; }
		setTimeout(function () {
			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('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 stepFrame(dir) {
		var v = activeVideo(), w = seekWindow(v); if (!v || !w) return;
		if (!v.paused) v.pause();
		var fps = (P.eng && P.eng.active() && P.eng.active().fps) || 30;
		var t = Math.max(w.start + 0.05, Math.min(w.end - 0.05, v.currentTime + dir / fps));
		try { v.currentTime = t; } catch (e) {}
		updateBar();
	}
	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((S.dvrBuffer || 300) / 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() {
		if (!S.scrubThumbs || !P.video || !P.armed || P.video.paused || P.video.readyState < 2 || !P.video.videoWidth) return;
		var v = P.video, 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 (!v || !w || !S.scrubThumbs || !P.thumbs || !P.thumbs.length || v !== P.video) { if (box) box.style.display = 'none'; return; }
		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) { if (box) box.style.display = 'none'; return; }
		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) { if (box) box.style.display = 'none'; return; }
		if (!box) { box = el('div', { id: 'cbx-thumb-prev' }); box.appendChild(document.createElement('canvas')); P.bar.appendChild(box); }
		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);
		}
		var barR = P.bar.getBoundingClientRect(), x = clientX - barR.left;
		box.style.left = Math.max(THUMB_W / 2 + 4, Math.min(barR.width - THUMB_W / 2 - 4, x)) + 'px';
		box.style.display = 'block';
	}
	function hideThumb() { var b = $('#cbx-thumb-prev'); if (b) b.style.display = 'none'; }
	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.block || (activeVideo() && activeVideo().closest(MAIN_PLAYER_SEL)) || activeVideo();
		if (!target) return;
		try { if (document.fullscreenElement) document.exitFullscreen(); else if (target.requestFullscreen) target.requestFullscreen(); else if (P.video && P.video.webkitEnterFullscreen) P.video.webkitEnterFullscreen(); } catch (e) {}
	}
	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 = {
		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><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="mute" aria-label="Mute or unmute">' + I.vol + '</button>' +
			'<input type="range" id="cbx-vol" min="0" max="100" value="100" aria-label="Volume">' +
			'<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>' +
			'<button data-act="live" class="cbx-live"><i></i>Live</button>' +
			'<span class="cbx-qwrap"><select id="cbx-qsel" class="cbx-q" aria-label="Video quality"><option value="">…</option></select></span>' +
			'<span id="cbx-behind"></span>' +
			'<button data-act="fs" title="Fullscreen">' + I.fs + '</button></div>';
	}

	function ensureBlock() {
		if (P.block && P.block.isConnected) return P.block;
		if (!roomName() || !S.rewindBar || 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);
		var touch = document.documentElement.classList.contains('cbx-touch');
		if (touch) { var grip = el('div', { id: 'cbx-hgrip', title: 'Drag to change the video height (double-tap to reset)' }, '<i></i>'); block.appendChild(grip); wireGrip(grip); }
		if (getComputedStyle(P.box).position === 'static') P.box.style.position = 'relative';
		P.baseH = P.box.clientHeight;
		P.box.appendChild(block);
		P.block = block; P.shell = shell; P.bar = bar;
		if (touch) {
			// an in-flow spacer right after the site's box: whatever we add on top of
			// the site's own height goes here, so the chat and tabs are pushed down
			// by normal layout instead of being covered
			var sp = el('div', { id: 'cbx-spacer' });
			P.box.insertAdjacentElement('afterend', sp); P.spacer = sp;
			document.documentElement.classList.add('cep-touch-block'); fitTouchHeight(true); window.addEventListener('resize', fitTouchHeight); wirePan(shell);
			[300, 1000, 2500].forEach(function (ms) { setTimeout(function () { if (P.block === block && block.isConnected) fitTouchHeight(); }, ms); });
			[300, 900, 2000, 4000].forEach(function (ms) { setTimeout(function () { if (P.block === block && block.isConnected) fitTouchHeight(true); }, ms); });
			setTimeout(function () { if (P.block === block && block.isConnected) dumpLayout('mount'); }, 1200);
		}
		document.documentElement.classList.add('cep-block');
		wireBar(bar, shell);
		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;
	}
	// phones: the site's box is short; grow it so the picture keeps its height
	// and the controls sit underneath instead of eating into it
	var TOUCH_MIN_PIC = 120; // below this the picture is unusable
	function touchBoxWidth() { var w = P.box ? P.box.clientWidth : 0; return w > 100 ? w : Math.min(innerWidth, document.documentElement.clientWidth || innerWidth); }
	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;
	}
	// natural height: the whole frame at the box width
	function touchNaturalPic() { return Math.round(touchBoxWidth() * videoAspect()); }
	// starting height when nothing was dragged: whole frame, 4:3, or half the screen
	function touchDefaultPic() {
		var w = touchBoxWidth(), m = S.mobileDefaultH;
		if (m === '43') return Math.round(w * 3 / 4);
		if (m === 'half') return Math.round(innerHeight * 0.5);
		if (m === 'tall') return Math.round(innerHeight * 0.62);
		return touchNaturalPic();
	}
	// letterbox at or below the natural height, crop (zoom) above it; re-checked every tick since the aspect arrives late
	function syncZoomClass(pic) {
		if (!P.block || !isTouch()) return;
		if (pic == null) { var sh = P.shell; pic = sh ? sh.clientHeight : 0; }
		var zoomed = pic > touchNaturalPic() + 2;
		if (P.block.classList.contains('cbx-zoomed') !== zoomed) {
			P.block.classList.toggle('cbx-zoomed', zoomed);
			if (!zoomed && P.video && P.video.style.objectPosition) { P.panX = null; P.video.style.objectPosition = ''; }
		}
	}
	// height is remembered per room, falling back to the global one
	function roomHeight() { var m = S.mobileHeightByRoom || {}, r = roomName(); return (r && m[r]) || S.mobileHeight || 0; }
	function setRoomHeight(pic) {
		var r = roomName(); S.mobileHeightByRoom = S.mobileHeightByRoom || {};
		if (r) { if (pic) S.mobileHeightByRoom[r] = pic; else delete S.mobileHeightByRoom[r]; } else S.mobileHeight = pic;
	}
	function touchMaxPic() { return Math.max(TOUCH_MIN_PIC, window.innerHeight - 80); }
	// one-off description of the site's wrappers around the player, for layout bugs on phones
	function dumpLayout(tag) {
		try {
			var box = P.box, out = [], n = box, want = box.getBoundingClientRect();
			var desc = function (e) {
				var cs = getComputedStyle(e), r = e.getBoundingClientRect();
				return (e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + (e.className && typeof e.className === 'string' ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : '')) +
					' pos=' + cs.position + ' h=' + Math.round(r.height) + ' top=' + Math.round(r.top) + ' ovf=' + cs.overflowY + (cs.height !== 'auto' ? ' css-h=' + cs.height : '') + (cs.top !== 'auto' ? ' css-top=' + cs.top : '');
			};
			for (var i = 0; i < 8 && n && n !== document.body; i++) { out.push(desc(n)); n = n.parentElement; }
			var after = P.spacer ? P.spacer.nextElementSibling : box.nextElementSibling;
			log('layout ' + tag + ': box wants bottom ' + Math.round(want.bottom) + '\n  chain: ' + out.join('\n  > ') + (after ? '\n  next: ' + desc(after) : '\n  next: none'));
		} catch (e) {}
	}
	function fitTouchHeight(force) {
		var box = P.box, bar = P.bar; if (!box || !bar || !bar.isConnected) return;
		var grip = $('#cbx-hgrip', P.block), strip = stripEl && P.block.contains(stripEl) ? stripEl : null;
		var def = touchDefaultPic();
		var pic = roomHeight() || def;
		pic = Math.max(TOUCH_MIN_PIC, Math.min(touchMaxPic(), pic));
		var h = pic + bar.offsetHeight + (grip ? grip.offsetHeight : 0) + (strip ? strip.offsetHeight : 0);
		if (force === true || Math.abs(box.clientHeight - h) > 1) {
			box.style.height = h + 'px';
			if (P.spacer) P.spacer.style.height = Math.max(0, h - (P.baseH || 0)) + 'px';
			// if the site's wrappers still clip us (fixed-height chat layouts), grow them too
			var wrapped = P.spacer ? P.spacer.getBoundingClientRect() : box.getBoundingClientRect();
			growTouchAncestors(box, wrapped.bottom);
			// the site places the panels below the player from its own measurements;
			// it may debounce, so poke it twice
			try { window.dispatchEvent(new Event('resize')); } catch (e) {}
			clearTimeout(fitTouchHeight._again);
			fitTouchHeight._again = setTimeout(function () { try { window.dispatchEvent(new Event('resize')); } catch (e) {} }, 350);
		}
		syncZoomClass(pic);
	}
	// the site's outer wrappers keep their own height on phones, so our taller
	// box just overflowed on top of the chat. Grow any ancestor that no longer
	// encloses the box so the content below (chat, tabs) is pushed down instead.
	function growTouchAncestors(box, bottom) {
		P.grown = P.grown || [];
		P.grown.forEach(function (n) { n.style.minHeight = ''; });
		if (bottom == null) bottom = box.getBoundingClientRect().bottom;
		var n = box;
		for (var i = 0; i < 8 && n.parentElement && n.parentElement !== document.body; i++) {
			n = n.parentElement;
			var pr = n.getBoundingClientRect();
			var csn = getComputedStyle(n), clips = (csn.overflowY === 'hidden' || csn.overflow === 'hidden' || csn.overflowY === 'clip') && pr.height < bottom - pr.top - 1;
			if (pr.bottom < bottom - 1 || clips) {
				if (P.grown.indexOf(n) < 0) {
					n._cbxStyle = { height: n.style.height, maxHeight: n.style.maxHeight, minHeight: n.style.minHeight };
					P.grown.push(n);
				}
				var cs = getComputedStyle(n);
				if (cs.maxHeight !== 'none') n.style.maxHeight = 'none';
				if (cs.overflowY === 'hidden' || cs.overflow === 'hidden') n.style.overflow = 'visible';
				n.style.height = 'auto';
				n.style.minHeight = Math.ceil(bottom - pr.top) + 'px';
			}
		}
	}
	function restoreTouchAncestors() {
		(P.grown || []).forEach(function (n) {
			var s = n._cbxStyle || {};
			n.style.height = s.height || ''; n.style.maxHeight = s.maxHeight || ''; n.style.minHeight = s.minHeight || ''; n.style.overflow = '';
			delete n._cbxStyle;
		});
		P.grown = [];
	}
	// zoomed picture: drag left/right to pan (vertical drags still scroll the page)
	function wirePan(shell) {
		var x0 = 0, y0 = 0, p0 = 50, t0 = 0, mode = null, hold = null, held2x = false;
		var SWIPE_SEC = 90; // a full-width swipe moves this many seconds
		shell.addEventListener('pointerdown', function (e) {
			if (e.pointerType === 'mouse') return;
			x0 = e.clientX; y0 = e.clientY; mode = 'wait'; held2x = false;
			p0 = P.panX == null ? 50 : P.panX;
			var v = activeVideo(); t0 = v ? v.currentTime : 0;
			clearTimeout(hold);
			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) < 10 && Math.abs(dy) < 10) return;
				clearTimeout(hold);
				if (Math.abs(dy) > Math.abs(dx)) { mode = 'scroll'; return; }
				mode = P.block.classList.contains('cbx-zoomed') ? 'pan' : (S.swipeSeek ? 'seek' : 'scroll');
			}
			if (mode === 'pan' && P.video) {
				P.panX = Math.max(0, Math.min(100, p0 - dx / Math.max(1, shell.clientWidth) * 100));
				P.video.style.objectPosition = P.panX + '% 50%';
			} else 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 { v.currentTime = 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 () {
			clearTimeout(hold);
			if (held2x) { var v = activeVideo(); if (v) setRate(v, 1); }
			shell._swallow = mode === 'seek' || mode === 'pan' || mode === 'hold';
			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); });
			if (stripEl && P.block.contains(stripEl)) { stripEl.remove(); stripEl._host = null; }
			try { P.block.remove(); } catch (e) {}
		}
		if (P.spacer) { try { P.spacer.remove(); } catch (e) {} P.spacer = null; }
		if (P.box) P.box.style.height = '';
		restoreTouchAncestors();
		window.removeEventListener('resize', fitTouchHeight);
		document.documentElement.classList.remove('cep-touch-block');
		P.block = P.shell = P.bar = null;
		document.documentElement.classList.remove('cep-block');
	}
	function wireGrip(grip) {
		var startY = 0, startH = 0;
		grip.addEventListener('pointerdown', function (e) { startY = e.clientY; startH = P.shell.getBoundingClientRect().height; try { grip.setPointerCapture(e.pointerId); } catch (err) {} grip.classList.add('cbx-dragging'); e.preventDefault(); });
		grip.addEventListener('pointermove', function (e) {
			if (!grip.classList.contains('cbx-dragging')) return;
			var pic = Math.round(Math.max(TOUCH_MIN_PIC, Math.min(touchMaxPic(), startH + (e.clientY - startY))));
			// snap to the default (full 16:9 frame) so it's easy to land back on it
			if (Math.abs(pic - touchDefaultPic()) < 10) pic = 0; // back at the default: forget the override
			setRoomHeight(pic);
			fitTouchHeight(true);
			if (S.debug) dumpLayout('drag');
		});
		['pointerup', 'pointercancel'].forEach(function (ev) { grip.addEventListener(ev, function () { if (!grip.classList.contains('cbx-dragging')) return; grip.classList.remove('cbx-dragging'); save(); }); });
		grip.addEventListener('dblclick', function () { setRoomHeight(0); P.panX = null; if (P.video) P.video.style.objectPosition = ''; save(); fitTouchHeight(); });
	}
	function wireBar(bar, shell) {
		['pointerdown', 'mousedown', 'touchstart'].forEach(function (ev) { bar.addEventListener(ev, function (e) { e.stopPropagation(); }, 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); });
		scrub.addEventListener('pointermove', function (e) { if (scrub._held) seekAt(e.clientX); if (scrub._held || e.pointerType === 'mouse') showThumbAt(scrub, e.clientX); });
		['pointerup', 'pointercancel'].forEach(function (ev) { scrub.addEventListener(ev, function () { scrub._held = false; hideThumb(); }); });
		scrub.addEventListener('pointerleave', function () { if (!scrub._held) hideThumb(); });
		var vol = $('#cbx-vol', bar);
		vol.addEventListener('input', function () { var v = activeVideo(); if (!v) return; userHasInteracted = true; v.volume = vol.value / 100; v.muted = vol.value === '0'; });
		var qsel = $('#cbx-qsel', bar);
		qsel.addEventListener('change', function () { qsel._open = false; pickQuality(qsel.value); });
		qsel.addEventListener('pointerdown', function () { qsel._open = true; fillQualitySelect(qsel, true); }, true);
		qsel.addEventListener('focus', function () { qsel._open = true; });
		qsel.addEventListener('blur', function () { qsel._open = false; });
		fillQualitySelect(qsel, false);
		var lastTap = 0, lastX = 0, single = null;
		shell.addEventListener('click', function (e) {
			if (shell._swallow) { shell._swallow = false; 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);
			single = setTimeout(function () { single = null; 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, fprev: function () { stepFrame(-1); }, fnext: function () { stepFrame(1); }, loop: cycleLoop,
		mark: addBookmark, stats: toggleStats
	};
	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.dvrBuffer) : 0, 1); }

	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), qsel: $('#cbx-qsel', 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), behind = Math.max(0, w.end - v.currentTime), win = scrubWindow(v, w);
		var pos = (scrub._held || fromScrub) && scrub._pos != null ? scrub._pos : 1 - (w.end - v.currentTime) / win;
		pos = Math.min(1, Math.max(0, pos));
		// timeline runs oldest -> live, left to right. The grey band is what is
		// actually held; orange runs from the start of the held part to the thumb.
		var heldFrom = Math.max(0, 1 - span / win);
		var held = R.held, fill = R.fill;
		held.style.left = (heldFrom * 100).toFixed(2) + '%'; held.style.width = ((1 - heldFrom) * 100).toFixed(2) + '%';
		fill.style.left = held.style.left; fill.style.width = (Math.max(0, pos - heldFrom) * 100).toFixed(2) + '%';
		R.thumb.style.left = (pos * 100).toFixed(2) + '%';
		at.style.left = (pos * 100).toFixed(2) + '%';
		at.style.transform = pos < 0.08 ? 'translateX(-10%)' : pos > 0.92 ? 'translateX(-90%)' : 'translateX(-50%)';
		at.textContent = behind < 2 ? 'live' : '−' + fmtTime(behind);
		var t = ours && P.eng ? P.eng.active() : null, mb = t && t.bw ? ' · ' + Math.round(span * t.bw / 8 / 1048576) + ' MB' : '';
		var atMax = ours && span >= (P.heldSec || S.dvrBuffer) - 8, warming = ours && !P.armed;
		bar.classList.toggle('cbx-warming', warming);
		var rate = v.playbackRate !== 1 ? v.playbackRate + '× · ' : '';
		var health = ours && P.fwdBuf != null && S.showHealth ? ' · ' + Math.round(P.fwdBuf) + 's ahead' + (P.stalls ? ' · ' + P.stalls + ' stall' + (P.stalls > 1 ? 's' : '') : '') : '';
		out.textContent = warming ? 'Rewind ready in ' + Math.max(0, Math.ceil(10 - span)) + 's…'
			: rate + (behind < 2 ? fmtTime(span) + (atMax ? ' (max)' : ' / ' + fmtTime(win)) + ' held' + mb : '−' + fmtTime(behind) + ' of ' + fmtTime(span) + (atMax ? ' (max)' : '')) + health;
		out.title = (atMax && P.heldNote ? P.heldNote + '. ' : '') + (health ? 'Forward buffer and stall count for this session' : '');
		bar.classList.toggle('cbx-behind-live', behind >= 2);
		if (v.playbackRate > 1 && behind < 1.5) setRate(v, 1);
		// loop markers on the timeline; drop the loop once its start is trimmed away
		if (P.loopA != null && P.loopA < w.start) { clearLoop(); toast('Loop dropped — its start left the buffer'); }
		var mk = R.marks || (R.marks = (function () { var d = el('div', { 'class': 'cbx-marks' }); scrub.appendChild(d); return d; })());
		var markAt = function (t) { return ((1 - (w.end - t) / win) * 100).toFixed(2) + '%'; };
		pruneMarks(w);
		var mkHtml = (P.loopA != null ? '<i style="left:' + markAt(P.loopA) + '"></i>' : '') + (P.loopB != null ? '<i style="left:' + markAt(P.loopB) + '"></i>' : '') +
			(P.marks || []).map(function (t) { return '<i class="cbx-bm" style="left:' + markAt(t) + '"></i>'; }).join('') +
			(S.tipMarks ? (P.tips || []).map(function (t) { return '<i class="cbx-tip" style="left:' + markAt(t) + '"></i>'; }).join('') : '');
		if (mk._html !== mkHtml) { mk._html = mkHtml; mk.innerHTML = mkHtml; }
		if (S.statsOverlay) renderStats(v, w, behind);
		var pIcon = v.paused ? I.play : I.pause; if (R.pause._icon !== pIcon) { R.pause._icon = pIcon; R.pause.innerHTML = pIcon; }
		var 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 (document.activeElement !== vol) vol.value = v.muted ? 0 : Math.round(v.volume * 100);
		var qsel = R.qsel;
		if (!qsel._open) fillQualitySelect(qsel, false);
	}

	/* ---- deep rewind ---- */
	function installDvr() {
		var user = roomName();
		if (!S.dvrMode || 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;
			site.muted = true; site.style.opacity = '0'; site.style.pointerEvents = 'none';
			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);
				v.addEventListener('loadedmetadata', function () { if (isTouch()) fitTouchHeight(); }, { once: true });
				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); }
	}
	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;
				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');
				setTimeout(dropSiteQuality, 500);
				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;
				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
	var PARK_RATE = 0.25;
	function parkSite(on) {
		var site = P.site && P.site.isConnected ? P.site : null; if (!site) return;
		try {
			if (on) {
				if (site.paused) { var pr = site.play(); if (pr && pr.catch) pr.catch(function () {}); }
				if (site.playbackRate !== PARK_RATE) { site.playbackRate = PARK_RATE; site.muted = true; log('site player parked at ' + PARK_RATE + 'x'); }
			} else if (site.playbackRate !== 1) { site.playbackRate = 1; }
		} 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');
				if (S.parkSite) 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 (S.parkSite && P.site && P.site.playbackRate !== PARK_RATE && 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) {}
		}
		updateBar();
	}
	function removeDvr() { dropDvr(); removeBlock(); }
	function backToSitePlayer() { P.suspended = true; P.forcedSite = true; removeDvr(); toast('Site player restored for this page'); updatePlayerToggle(); }
	function onPlayerNavigate() { P.suspended = false; P.forcedSite = false; P.attempt = 0; removeDvr(); }

	/* ---- player mode (off / site / deep) ---- */
	function setPlayerMode(mode) {
		var on = mode === 'deep' || mode === true;
		S.deepPlayer = on; S.playerMode = on ? 'deep' : 'off'; S.rewindBar = on; S.dvrMode = on;
		save();
		if (!on) removeDvr();
		else { P.suspended = false; P.forcedSite = false; P.attempt = 0; playerTick(); }
		refreshPanel(); updatePlayerToggle();
	}

	/* ---- floating player pill (beside the gear): Deep ⇄ Site ---- */
	function playerIsDeep() { return S.deepPlayer && !P.forcedSite; }
	function updatePlayerToggle() {
		if (!dockEl) return;
		var deep = playerIsDeep(), nat = deep && P.native;
		dockEl.innerHTML = '<i class="cbx-dot cbx-dot-' + (deep ? (nat ? 'nat' : 'deep') : 'off') + '"></i>' + (deep ? (nat ? 'Native' : 'Deep') : 'Site') + (dataSaverOn() ? ' <small>saver</small>' : '');
		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+)'
			: deep ? 'Deep rewind player is on — click for the plain site player (P)' : 'Plain site player — click for deep rewind (P)';
		dockEl.setAttribute('aria-pressed', deep ? 'true' : 'false');
	}
	function togglePlayer() {
		var on = !playerIsDeep();
		P.forcedSite = false; P.suspended = false; P.attempt = 0;
		setPlayerMode(on ? 'deep' : 'off');
		toast(on ? 'Deep rewind player on' : 'Site player');
	}

	/* ---- 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 dropSiteQuality() {
		if (!P.video) return;
		var btn = $(QUALITY_BTN);
		if (!btn) { log('no quality button, site player stays on its own setting'); return; }
		document.documentElement.classList.add('cep-quiet-menu');
		clickHard(btn);
		setTimeout(function () {
			var opts = $$(QUALITY_OPT).map(parseQualityLabel).filter(Boolean);
			if (opts.length) { var lowest = opts.sort(function (a, b) { return a.height - b.height; })[0]; clickHard(lowest.node); log('site player dropped to', lowest.label); }
			setTimeout(function () { closeSiteMenu(btn); }, 250);
		}, 600);
	}

	function playerTick() {
		if (!roomName()) { if (P.block) removeDvr(); placeDock(); return; }
		if (P.block && (!P.box || !P.box.isConnected)) { log('player box replaced; 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;
		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);
	}

	function fillQualitySelect(sel, force) {
		var v = activeVideo();
		if (!v) return;
		if (v === P.video && P.eng) {
			var cur = P.eng.active();
			var html = '<option value="auto">Auto</option>' + P.eng.tracks().map(function (t) { return '<option value="t' + t.id + '">' + t.label + '</option>'; }).join('');
			if (sel._cbxHtml !== html) { sel.innerHTML = html; sel._cbxHtml = html; }
			var want = P.eng.isAuto() ? 'auto' : (cur ? 't' + cur.id : 'auto');
			if (sel.value !== want) sel.value = want;
			return;
		}
		var h = v._cbxHls;
		var nowLabel = v.videoHeight ? v.videoHeight + 'p' : '…';
		// the placeholder is never blank: at worst it shows what is decoding now
		if (!sel._cbxHtml && sel.options.length === 1 && sel.options[0].value === '') sel.options[0].textContent = nowLabel;
		// site player: read its menu once, early, so the list is there before the first tap
		if (!h && !force && !siteQualityCache.opts.length && v.videoHeight && !sel._cbxReading && Date.now() - siteQualityCache.at > 15000) {
			force = true;
		}
		function render(opts, current) {
			var html = opts.map(function (o) { return '<option value="' + o.value + '">' + o.label + '</option>'; }).join('');
			html = (h ? '<option value="auto">Auto</option>' : '') + html;
			if (sel._cbxHtml !== html) { sel.innerHTML = html; sel._cbxHtml = html; }
			if (current != null && sel.value !== current) sel.value = current;
		}
		if (h && h.levels && h.levels.length) {
			var lvl = h.loadLevel >= 0 ? h.loadLevel : h.currentLevel;
			render(hlsLevelOptions(h), h.autoLevelEnabled && !v.classList.contains('cbx-dvr') ? 'auto' : 'h' + lvl);
			return;
		}
		if (!force && siteQualityCache.opts.length) {
			render(siteQualityCache.opts, v.videoHeight ? 's' + v.videoHeight + 'p' : null);
			return;
		}
		if (force && !sel._cbxReading) {
			sel._cbxReading = true;
			readSiteQualities(function (opts) {
				sel._cbxReading = false;
				if (opts.length) render(opts, v.videoHeight ? 's' + v.videoHeight + 'p' : null);
				else { sel.innerHTML = '<option value="">' + nowLabel + '</option>'; sel._cbxHtml = null; siteQualityCache.at = Date.now(); }
			});
		}
	}

	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 (S.dvrMode && !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 · Home start · End / 0 live · > 2× · < ½× · , . frame step · A loop · B bookmark · [ ] jump bookmarks · I stats · Q quality · P deep/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 'Home': seekToStart(); break;
				case 'End': case '0': goLive(); break;
				case '>': toggleCatchUp(); break;
				case '<': toggleSlow(); break;
				case ',': stepFrame(-1); break;
				case '.': stepFrame(1); 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': if (S.showStrip) toggleRecording(); else handled = false; break;
				case '?': toast(KEY_HELP, 7000); break;
				default: handled = false;
			}
			if (handled) { e.preventDefault(); e.stopPropagation(); }
		}, true);
	}


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

	function openWatchOnly(user) {
		user = user || roomName();
		if (!user) { toast('You are not in a room'); return; }
		closeWatchOnly();

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

		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');
			if (!first && S.tipMarks && P.video && P.armed && m.querySelector('.isTip')) { P.tips = P.tips || []; P.tips.push(P.video.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}',
		biggerCards:
			'.RoomCardGrid,.MoreRooms .list{grid-template-columns:repeat(auto-fill,minmax(240px,1fr))!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;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}' +
		'#cbx-block.cbx-zoomed #cbx-shell video.cbx-dvr{object-fit:cover}html.cbx-touch #cbx-shell{touch-action:pan-y}' +
		'#cbx-block:fullscreen{position:fixed;inset:0}' +
		'#cbx-block #cbx-bar,#cbx-block #cbx-strip,#cbx-block #cbx-hgrip{transition:opacity .3s ease}' +
		'#cbx-block.cbx-idle:fullscreen #cbx-bar,#cbx-block.cbx-idle:fullscreen #cbx-strip,#cbx-block.cbx-idle:fullscreen #cbx-hgrip{opacity:0;pointer-events:none}' +
		'#cbx-block.cbx-idle:fullscreen{cursor: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-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}' +
		'#cbx-bar button{flex:0 0 auto;min-width:30px;min-height:30px;border:0;border-radius:6px;background:transparent;color:#e8ebed;' +
		'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}' +
		'#cbx-bar .cbx-live{background:rgba(246,115,0,.25);padding:0 8px}#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:#f67300;color:#fff}' +
		'#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}' +
		'#cbx-scrub .cbx-held{position:absolute;left:0;top:0;bottom:0;border-radius:3px;background:rgba(255,255,255,.28);width:100%}' +
		'#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}' +
		'#cbx-vol{width:56px;flex:none;accent-color:#f67300;height:14px;cursor:pointer;margin:0}' +
		'.cbx-qwrap{position:relative;display:inline-block;flex:0 0 auto;width:auto!important}' +
		'#cbx-bar select.cbx-q{appearance:none;-webkit-appearance:none;border:0;border-radius:8px;color:#cfd6db;width:auto!important;max-width:110px;' +
		'background:rgba(255,255,255,.08) url("data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 10 6%27%3E%3Cpath d=%27M1 1l4 4 4-4%27 fill=%27none%27 stroke=%27%238b969e%27 stroke-width=%271.5%27/%3E%3C/svg%3E") no-repeat right 6px center/9px 6px;' +
		'font:11px/1 system-ui,sans-serif;min-height:30px;min-width:64px;padding:0 18px 0 8px;cursor:pointer}' +
		'#cbx-bar select.cbx-q option{background:#14171a;color:#e8ebed}' +
		'#cbx-bar.cbx-ours select.cbx-q{color:#e8ebed;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}' +
		'#cbx-spacer{display:block;width:100%;height:0;flex:none}' +
		'#cbx-block > #cbx-strip{flex:none;border-bottom:0;padding:4px 6px}' +
		'#cbx-hgrip{flex:none;height:18px;display:flex;align-items:center;justify-content:center;background:#14171a;border-top:1px solid #2a3138;cursor:ns-resize;touch-action:none;user-select:none}' +
		'#cbx-hgrip i{width:44px;height:5px;border-radius:3px;background:#5a646c;display:block}#cbx-hgrip.cbx-dragging i{background:#f67300}' +
		/* site controls out of the way while our block is up */
		'html.cep-block .theater-video-controls{opacity:0!important;pointer-events:none!important}' +
		'html.cep-block :is(div,ul,section):has(> [data-testid="quality-option"]),html.cep-block :is(div,ul,section):has(> * > [data-testid="quality-option"]){visibility:hidden!important}' +
		'html.cep-quiet-menu [data-testid="quality-option"],html.cep-quiet-menu :is(div,ul,section):has(> [data-testid="quality-option"]),' +
		'html.cep-quiet-menu :is(div,ul,section):has(> * > [data-testid="quality-option"]){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-vol,#cbx-behind{display:none}' +
		'.cbx-row-btns{flex-wrap:nowrap;justify-content:space-between;gap:4px}' +
		'#cbx-bar button{min-width:0;padding:0 2px;flex:0 0 auto;width:42px}#cbx-bar button b{display:none}' +
		'#cbx-bar .cbx-live{width:auto;padding:0 8px;margin:0 3px}' +
		'#cbx-bar select.cbx-q{min-width:0;width:62px;padding:0 12px 0 4px;font-size:11px}.cbx-qwrap{flex:0 0 auto;margin:0 3px}}' +
		'html.cbx-touch #cbx-bar button,html.cbx-touch #cbx-bar select.cbx-q{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{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px}' +
		'#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 Video 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
	 * ================================================================== */

	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">Add</button>' +
			'<button id="cbx-multi-reload" class="cbx-b">Reload</button>' +
			'<button id="cbx-multi-mute" class="cbx-b">Mute all</button>' +
			'<button id="cbx-multi-random" class="cbx-b">Random</button>' +
			'<button id="cbx-multi-rm-off" class="cbx-b">Remove offline</button>' +
			'<button id="cbx-multi-rm-all" class="cbx-b">Remove all</button>' +
			'<select id="cbx-multi-cols" aria-label="Columns"><option value="0">Auto columns</option><option value="2">2 columns</option><option value="3">3 columns</option><option value="4">4 columns</option><option value="5">5 columns</option><option value="6">6 columns</option></select>' +
			'<button id="cbx-multi-semi" class="cbx-b" title="F10">Hide toolbar</button>' +
			'<button id="cbx-multi-fs" class="cbx-b" title="F11">Fullscreen</button>' +
			'<button id="cbx-multi-share" class="cbx-b" title="Copy a link that opens this set of rooms">Copy share 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'; }

		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>' +
				'<button class="cbx-x" aria-label="Remove ' + user + '">&times;</button>' +
				'<span class="cbx-state">Loading</span>' +
				(S.multiShowSubject ? '<span class="cbx-subject"></span>' : ''));
			if (S.multiResizable) cell.classList.add('cbx-resizable');
			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 () {
				try { if (video._cbxHls) video._cbxHls.destroy(); } catch (e) {}
				cell.remove();
				jsonSet(MULTI_KEY, jsonGet(MULTI_KEY, []).filter(function (u) { return u !== user; }));
				sync();
			});

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

			if (S.multiShowSubject || S.multiHidePrivate) {
				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 (S.multiHidePrivate && st && st !== 'public') { cell.style.display = 'none'; }
					}).catch(function () {});
			}

			hlsFor(user).then(function (url) {
				if (!url) {
					state.textContent = '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);
		});
		$('#cbx-multi-mute', root).addEventListener('click', function () {
			$$('.cbx-cam video', grid).forEach(function (v) { v.muted = true; });
			$$('.cbx-cam', grid).forEach(function (c) { c.classList.remove('cbx-live-audio'); });
		});

		$('#cbx-multi-random', root).addEventListener('click', function () { fillRandom(addCam); });

		function removeCell(c) {
			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 cols = $('#cbx-multi-cols', root);
		cols.value = String(S.multiCols || 0);
		function applyCols() { if (S.multiCols) grid.setAttribute('data-cols', S.multiCols); else grid.removeAttribute('data-cols'); }
		cols.addEventListener('change', function () { S.multiCols = parseInt(cols.value, 10) || 0; save(); applyCols(); });
		applyCols();

		function toggleBare(on) {
			if (on == null) on = !root.classList.contains('cbx-bare');
			root.classList.toggle('cbx-bare', on);
			$('#cbx-multi-semi', root).textContent = on ? 'Show toolbar' : 'Hide toolbar';
		}
		function toggleFs() {
			try { if (document.fullscreenElement) document.exitFullscreen(); else root.requestFullscreen(); } catch (e) {}
		}
		$('#cbx-multi-semi', root).addEventListener('click', function () { toggleBare(); });
		$('#cbx-multi-fs', root).addEventListener('click', toggleFs);
		document.addEventListener('fullscreenchange', function () { toggleBare(!!document.fullscreenElement); });
		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 (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(',');
			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 };
	// picture filters live for the page only; a reload gives a clean picture
	var VF_DEFAULT = { bright: 100, contrast: 100, sat: 100, hue: 0, blur: 0, sepia: false, 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.hue) parts.push('hue-rotate(' + vf.hue + 'deg)');
		if (vf.blur) parts.push('blur(' + vf.blur + 'px)');
		if (vf.sepia) parts.push('sepia(1)');
		if (vf.invert) parts.push('invert(1)');
		return parts.join(' ');
	}
	function pictureTouched() {
		return xform.rot !== 0 || xform.flip !== 1 || xform.zoom !== 1 || !!filterCSS();
	}

	function applyTransform() {
		var v = activeVideo();
		if (!v) return;
		var t = pictureTouched() ? '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';
		var f = filterCSS();
		if (v.style.filter !== f) v.style.filter = f;
		syncStrip();
	}
	function resetTransform() {
		xform = { rot: 0, flip: 1, zoom: 1 };
		for (var k in VF_DEFAULT) vf[k] = VF_DEFAULT[k];
		var v = activeVideo();
		if (v) { v.style.transform = ''; v.style.filter = ''; }
		syncStrip();
	}

	/* ================================================================== *
	 * 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);
		syncStrip();
		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 = [];
		syncStrip();
		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, stripEl, 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 stripEl = null, ddEl = null, ddOpen = null, ddAnchor = null, ddCtx = null;

	var STRIP_CSS = [
		'#cbx-strip{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 8px;margin:0;box-sizing:border-box;width:100%;',
		'font:13px/1 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--cbx-fg);background:var(--cbx-bg);border-top:1px solid var(--cbx-line);border-bottom:1px solid var(--cbx-line)}',
		'#cbx-strip.cbx-hidden{display:none}',
		'#cbx-strip *{box-sizing:border-box}',
		'#cbx-strip button{line-height:1;margin:0;text-transform:none;letter-spacing:normal;min-width:0;height:auto;box-shadow:none}',
		'#cbx-strip input[type=range]{height:auto;width:auto;padding:0;border:0;background:transparent;box-shadow:none}',
		'#cbx-strip .cbx-zoom{display:flex;align-items:center;gap:6px;flex:1 1 150px;min-width:120px;padding:0 2px}',
		'#cbx-strip .cbx-pgrp{display:inline-flex;gap:3px;flex:none}#cbx-strip .cbx-sb.cbx-ico{padding:6px 7px}#cbx-strip .cbx-sb svg{width:15px;height:15px;fill:none}',
		'#cbx-strip .cbx-sb.cbx-on{background:var(--cbx-accent);border-color:var(--cbx-accent);color:#fff}',
		'#cbx-strip .cbx-more,#cbx-strip .cbx-break{display:none}',
		// phones: row 1 = zoom, 2x, snapshot, record, more · row 2 = Picture, Sound, Room
		'html.cbx-touch #cbx-strip{gap:4px;padding:5px 6px}html.cbx-touch #cbx-strip .cbx-sb{padding:8px 7px;min-height:36px}',
		'html.cbx-touch #cbx-strip .cbx-pgrp{display:contents}html.cbx-touch #cbx-strip .cbx-rare{display:none}',
		'html.cbx-touch #cbx-strip .cbx-more{display:inline-flex;order:5;font-size:16px;line-height:1;padding:8px 9px}',
		'html.cbx-touch #cbx-strip .cbx-zoom{order:1;flex:1 1 70px;min-width:70px;max-width:120px}html.cbx-touch #cbx-strip .cbx-zoom label,html.cbx-touch #cbx-strip #cbx-zoom-val{display:none}',
		'html.cbx-touch #cbx-strip .cbx-sb[data-act="fast"]{order:2}html.cbx-touch #cbx-strip .cbx-sb[data-act="snap"]{order:3}',
		'html.cbx-touch #cbx-strip .cbx-sb[data-act="mark"],html.cbx-touch #cbx-strip .cbx-sb[data-act="loop"]{display:inline-flex;order:3}',
		'html.cbx-touch #cbx-strip .cbx-recgrp{order:4}html.cbx-touch #cbx-strip .cbx-recgrp .cbx-sb[data-dd="rec"]{display:none}html.cbx-touch #cbx-strip #cbx-rec-txt{display:none}',
		'html.cbx-touch #cbx-strip .cbx-break{display:block;order:6;flex:1 1 100%;height:0}',
		'html.cbx-touch #cbx-strip .cbx-sb[data-dd="pic"],html.cbx-touch #cbx-strip .cbx-sb[data-dd="snd"],html.cbx-touch #cbx-strip .cbx-sb[data-dd="room"]{order:7;flex:1 1 0;justify-content:center}',
		'#cbx-strip .cbx-zoom label{font-size:12px;color:var(--cbx-dim);flex:none}',
		'#cbx-strip .cbx-zoom input{flex:1;min-width:60px;accent-color:var(--cbx-accent);margin:0;cursor:pointer}',
		'#cbx-strip .cbx-zoom b{flex:none;width:34px;font-size:11px;font-weight:400;color:var(--cbx-dim);text-align:right}',
		'#cbx-strip .cbx-sb{appearance:none;font:inherit;font-size:12px;padding:7px 10px;border-radius:8px;border:1px solid var(--cbx-line);',
		'background:var(--cbx-bg-2);color:var(--cbx-fg);cursor:pointer;white-space:nowrap;display:inline-flex;align-items:center;gap:6px;-webkit-tap-highlight-color:transparent}',
		'#cbx-strip .cbx-sb:hover,#cbx-strip .cbx-sb.cbx-on{border-color:var(--cbx-dim)}',
		'#cbx-strip .cbx-sb:focus-visible{outline:2px solid var(--cbx-accent);outline-offset:1px}',
		'#cbx-strip .cbx-sb.cbx-live{color:var(--cbx-accent)}',
		'#cbx-strip .cbx-sb i{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid currentColor;opacity:.7}',
		'#cbx-strip .cbx-recgrp{display:inline-flex}',
		'#cbx-strip .cbx-recgrp .cbx-sb:first-child{border-radius:8px 0 0 8px}',
		'#cbx-strip .cbx-recgrp .cbx-sb:last-child{border-radius:0 8px 8px 0;border-left:0;padding:7px 8px}',
		'#cbx-rec-dot{width:9px;height:9px;border-radius:50%;background:var(--cbx-dim);flex:none}',
		'#cbx-rec.cbx-rec-on #cbx-rec-dot{background:#e33;animation:cbx-blink 1.2s ease-in-out infinite}',
		'@keyframes cbx-blink{50%{opacity:.35}}',
		'#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}',
		'@media (prefers-reduced-motion:reduce){#cbx-rec.cbx-rec-on #cbx-rec-dot{animation:none}}'
	].join('');

	function stripHTML() {
		return '<span class="cbx-pgrp" role="group" aria-label="Playback">' +
			'<button class="cbx-sb cbx-ico cbx-rare" data-act="fprev" title="Previous frame (,)">' + I.prev + '</button>' +
			'<button class="cbx-sb cbx-ico cbx-rare" data-act="fnext" title="Next frame (.)">' + I.next + '</button>' +
			'<button class="cbx-sb cbx-rare" data-act="slow" title="Slow motion 0.5× (&lt;)">½×</button>' +
			'<button class="cbx-sb" data-act="fast" title="Catch up at 2× (&gt;)">2×</button>' +
			'<button class="cbx-sb cbx-ico cbx-rare" data-act="loop" title="Set loop start (A)">' + I.loop + '</button>' +
			'<button class="cbx-sb cbx-ico cbx-rare" data-act="mark" title="Bookmark this moment (B). [ and ] jump between bookmarks.">' + I.flag + '</button>' +
			'<button class="cbx-sb cbx-ico" data-act="snap" title="Save a frame (S)">' + I.cam + '</button>' +
			(S.clipSave ? '<button class="cbx-sb cbx-ico cbx-rare" data-act="clip" title="Save the buffer (D)">' + I.save + '</button>' : '') +
			'<button class="cbx-sb cbx-ico cbx-rare" data-act="pip" title="Picture in picture">' + I.pip + '</button>' +
			'<button class="cbx-sb cbx-more" data-dd="more" aria-haspopup="true" aria-label="More player tools">…</button></span>' +
			'<i class="cbx-break" aria-hidden="true"></i>' +
			'<div class="cbx-zoom"><label for="cbx-zoom">Zoom</label><input type="range" id="cbx-zoom" min="50" max="300" step="5" value="100" title="Double-click to reset"><b id="cbx-zoom-val">1.0×</b></div>' +
			'<button class="cbx-sb" data-dd="pic" aria-haspopup="true">Picture<i></i></button>' +
			'<button class="cbx-sb" data-dd="snd" aria-haspopup="true">Sound<i></i></button>' +
			'<span class="cbx-recgrp"><button class="cbx-sb" id="cbx-rec" title="Start or stop recording (R)"><span id="cbx-rec-dot"></span><span id="cbx-rec-txt">Record</span></button>' +
			'<button class="cbx-sb" data-dd="rec" aria-label="Recording options" aria-haspopup="true"><i></i></button></span>' +
			'<button class="cbx-sb" data-dd="room" aria-haspopup="true">Room<i></i></button>';
	}

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

	function ddHTML(id) {
		var user = roomName();
		if (id === 'pic') return '<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-vf-hue', 'Hue', -180, 180, 5, vf.hue, '°') +
			rangeHTML('cbx-vf-blur', 'Blur', 0, 10, 0.5, vf.blur, 'px') +
			'<div class="cbx-chips">' + chipHTML('rot', 'Rotate', xform.rot !== 0) + chipHTML('flip', 'Flip', xform.flip === -1) +
			chipHTML('sepia', 'Sepia', vf.sepia) + 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 '<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 === 'more') return '<h3>Player</h3>' +
			'<button class="cbx-act" data-do="fprev">Previous frame</button>' +
			'<button class="cbx-act" data-do="fnext">Next frame</button>' +
			'<button class="cbx-act" data-do="slow">' + (activeVideo() && activeVideo().playbackRate < 1 ? 'Normal speed' : 'Slow motion ½×') + '</button>' +
			'<button class="cbx-act" data-do="loop">' + (P.loopB != null ? 'Clear loop' : P.loopA != null ? 'Set loop end' : 'Set loop start') + '</button>' +
			'<button class="cbx-act" data-do="mark">Bookmark this moment</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>' : '') +
			(S.clipSave ? '<button class="cbx-act" data-do="clip">Save the buffer</button>' : '') +
			'<button class="cbx-act" data-do="pip">Picture in picture</button>' +
			'<button class="cbx-act" data-do="stats">' + (S.statsOverlay ? 'Hide stats' : 'Show stats') + '</button>' +
			'<h3>Recording</h3><button class="cbx-act" data-do="recopts">Recording options…</button>';

		if (id === 'rec') return '<h3>Recording</h3>' +
			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 '<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');
		if (stripEl) $$('.cbx-sb', stripEl).forEach(function (b) { b.classList.toggle('cbx-on', b === anchor); });
		placeDD(anchor);
		syncStrip();
		localizePanel();
	}

	function placeDD(anchor) {
		if (!ddEl || !ddOpen) return;
		anchor = anchor || ddAnchor;
		if (!anchor || !anchor.isConnected) { closeDD(); return; }
		var r = anchor.getBoundingClientRect(), w = ddEl.offsetWidth || 320, h = ddEl.offsetHeight || 200;
		var left = Math.min(Math.max(8, r.left), innerWidth - w - 8);
		var top = r.bottom + 6;
		if (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.classList.remove('cbx-on');
		if (stripEl) $$('.cbx-sb', stripEl).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-vf-hue') { vf.hue = v; if (lbl) lbl.textContent = v + '°'; applyTransform(); }
			else if (id === 'cbx-vf-blur') { vf.blur = v; if (lbl) lbl.textContent = v + 'px'; applyTransform(); }
			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-vf-hue': 0, 'cbx-vf-blur': 0, '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 doBtn = e.target.closest('[data-do]');
			if (doBtn) {
				var what = doBtn.getAttribute('data-do'), anchor = ddAnchor;
				e.preventDefault(); e.stopPropagation();
				if (what === 'recopts') { closeDD(); openDD('rec', anchor); return; }
				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) {
				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 === 'sepia') vf.sepia = !vf.sepia;
				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.');
	}

	function buildStrip() {
		if (stripEl) return stripEl;
		ensureStripCSS();
		stripEl = el('div', { id: 'cbx-strip', role: 'toolbar', 'aria-label': 'Player tools' }, stripHTML());
		stripEl.addEventListener('click', function (e) {
			var b = e.target.closest('button');
			if (!b) return;
			if (b.id === 'cbx-rec') { toggleRecording(); return; }
			var act = b.getAttribute('data-act');
			if (act) { runAct(act); return; }
			var dd = b.getAttribute('data-dd');
			if (dd) openDD(dd, b);
		});
		var zoom = $('#cbx-zoom', stripEl);
		zoom.addEventListener('input', function () {
			xform.zoom = parseInt(zoom.value, 10) / 100;
			$('#cbx-zoom-val', stripEl).textContent = xform.zoom.toFixed(1) + '×';
			applyTransform();
		});
		zoom.addEventListener('dblclick', function () { zoom.value = 100; zoom.dispatchEvent(new Event('input')); });
		return stripEl;
	}

	// mount under the player box; if that spot turns out to be clipped or
	// covered, step out one ancestor at a time until the strip is visible
	function ensureStrip() {
		if (IS_MULTI) return;
		var user = roomName();
		if (!user || !S.showStrip) { if (stripEl && stripEl.isConnected) { closeDD(); stripEl.remove(); } return; }
		var site = P.site && P.site.isConnected ? P.site : siteVideo();
		if (!site) return;
		var box = P.box && P.box.isConnected ? P.box : playerBox(site);
		if (!box) return;
		buildStrip();
		if (isTouch() && P.block && P.block.isConnected) {
			if (stripEl.parentElement !== P.block) {
				var grip = $('#cbx-hgrip', P.block);
				if (grip) P.block.insertBefore(stripEl, grip); else P.block.appendChild(stripEl);
				stripEl._host = P.block; stripEl._nextTry = 0;
				stripEl.classList.remove('cbx-hidden');
				log('strip mounted inside the player block');
				fitTouchHeight();
			}
			syncStrip();
			return;
		}
		if (stripEl.isConnected && stripEl._host === box && stripEl._host.isConnected) return;
		if (stripEl._nextTry && Date.now() < stripEl._nextTry) return;
		var host = box, boxBottom = box.getBoundingClientRect().bottom;
		var canTest = !document.hidden && !(panelEl && panelEl.classList.contains('cbx-on')) && !ddOpen;
		for (var i = 0; i < 5 && host; i++) {
			var pos = getComputedStyle(host).position;
			if (pos !== 'absolute' && pos !== 'fixed') {
				host.insertAdjacentElement('afterend', stripEl);
				var r = stripEl.getBoundingClientRect();
				var ok = r.height >= 20 && r.width >= 150 && r.top >= boxBottom - 4;
				if (ok && canTest && r.top >= 0 && r.top < innerHeight) {
					var hit = document.elementFromPoint(Math.min(innerWidth - 1, r.left + 12), Math.min(innerHeight - 1, r.top + r.height / 2));
					ok = !!hit && stripEl.contains(hit);
				}
				if (ok) break;
			}
			host = host.parentElement && host.parentElement !== document.body ? host.parentElement : null;
		}
		if (!host) { stripEl.remove(); stripEl._nextTry = Date.now() + 5000; log('strip: no place found under the player'); return; }
		stripEl._nextTry = 0;
		stripEl._host = box;
		stripEl.classList.remove('cbx-hidden');
		log('strip mounted after', host ? (host.id ? '#' + host.id : host.className) : '?');
		syncStrip();
	}

	function syncStrip() {
		if (!stripEl) return;
		var z = $('#cbx-zoom', stripEl);
		if (z && document.activeElement !== z) { z.value = Math.round(xform.zoom * 100); $('#cbx-zoom-val', stripEl).textContent = xform.zoom.toFixed(1) + '×'; }
		var pic = $('.cbx-sb[data-dd="pic"]', stripEl);
		if (pic) pic.classList.toggle('cbx-live', pictureTouched());
		var snd = $('.cbx-sb[data-dd="snd"]', stripEl);
		if (snd) snd.classList.toggle('cbx-live', S.volumeBoost !== 100 || S.voiceBoost);
		var rb = $('#cbx-rec', stripEl), rt = $('#cbx-rec-txt', stripEl);
		if (rb) {
			var on = recording();
			rb.classList.toggle('cbx-rec-on', on);
			rb.setAttribute('aria-pressed', on ? 'true' : 'false');
			if (rt) rt.textContent = on ? fmtTime((Date.now() - rec.since) / 1000) : 'Record';
		}
		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;gap:7px;height:40px;padding:0 14px 0 12px;border-radius:20px;',
		'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{opacity:1}',
		'.cbx-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none;background:#8b969e}',
		'.cbx-dot-deep{background:#3ad07a}.cbx-dot-off{background:#8b969e}.cbx-dot-nat{background:#f6a25e}',
		'#cbx-launcher svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round}',
		'#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;padding:12px 0;border:0}',
		'#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-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:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}',
		'#cbx-multi input[type=text]{flex:1;min-width:150px;padding:9px 11px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed;font-size:16px}',
		'#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{position:absolute;left:8px;top:7px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.65);font-size:12px}',
		'.cbx-cam .cbx-x{position:absolute;right:6px;top:5px;width:24px;height:24px;border-radius:12px;border:0;background:rgba(0,0,0,.65);color:#fff;font-size:16px;line-height:1;cursor:pointer}',
		'.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-cam.cbx-resizable{resize:both;overflow:auto;aspect-ratio:auto;min-width:180px;min-height:110px;height:190px}',
		'.cbx-cam.cbx-resizable video{height:100%}',
		'#cbx-multi select{font:inherit;font-size:13px;padding:8px 10px;border-radius:8px;border:1px solid #2a3138;background:#1c2126;color:#e8ebed}',
		'#cbx-multi-share{flex:1;min-width:150px;font-size:12px!important;color:#8b969e}',
		'.cbx-cam .cbx-name{cursor:grab}.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)}',
		'@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"><circle cx="12" cy="12" r="3"/><path d="M12 3v2M12 19v2M4.2 7.5l1.7 1M18.1 15.5l1.7 1M4.2 16.5l1.7-1M18.1 8.5l1.7-1"/></svg>';

	var TABS = [
		{ id: 'sound', label: 'Sound', groups: [
			{ title: null, 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 under the player.']
			] }
		] },
		{ id: 'video', label: 'Video', groups: [
			{ title: 'Rewind', items: [
				['deepPlayer', 'Deep rewind player', 'Our player over the site\'s, holding minutes of video you can scrub through. Off leaves the plain site player.'],
				['bigBuffer', 'Large rewind buffer (uses more RAM)', 'About 400 MB instead of 120 (150 vs 60 on phones). Longer windows at high quality; heavier on memory over long sessions.'],
				['parkSite', 'Slow the hidden site player', 'While deep rewind runs, the site\'s own player crawls at quarter speed so it fetches far less video underneath ours. Pausing it makes the site think it is stuck and rebuild it.'],
				['keyShortcuts', 'Keyboard shortcuts', 'Press ? on a room page for the list.'],
				['dblTapSeek', 'Double-tap the video to skip', 'Left third −10s, right third +10s, middle fullscreen. Single tap pauses.'],
				['swipeSeek', 'Swipe the video to rewind (phones)', 'Drag left or right across the picture to scrub the held buffer. Vertical drags still scroll.'],
				['holdFast', 'Hold the video for 2× (phones)', 'Press and hold to catch up at double speed; lifting returns to normal.'],
				['mobileFullQuality', 'Full quality for deep rewind on phones', 'Off keeps the pinned rendition at 480p or lower so the forward buffer stays healthy on a small memory budget.'],
				['showHealth', 'Show buffer health', 'Seconds buffered ahead and the stall count, in the player status text.'],
				['statsOverlay', 'Stats overlay on the picture', 'Resolution, bitrate, latency, buffers and dropped frames (I).'],
				['scrubThumbs', 'Preview frames on the scrubber', 'A small frame every 10 seconds, shown while you hover or drag the timeline.'],
				['fsAutoHide', 'Hide controls in fullscreen', 'Controls fade after 3 seconds without input.'],
				['mediaSession', 'Lock screen and headset controls', 'Play, pause and skip from the phone lock screen or headphones.'],
				['dataSaver', 'Data saver', 'Cap deep rewind at 360p and halve the buffer.'],
				['dataSaverAuto', 'Data saver on cellular or low battery', 'Turns itself on for mobile data, browser save-data mode, or under 20% battery on battery power.']
			] },
			{ title: 'Previews', items: [
				['inlinePreview', 'Keep previews inline', 'Stops previews jumping to fullscreen on iOS.'],
				['hoverPreview', 'Preview on hover', 'Press and hold on a phone.'],
				['previewInline', 'Play the preview in the thumbnail', 'Off shows it in a corner box instead.'],
				['previewMuted', 'Previews start muted', null]
			] },
			{ title: 'Player', items: [
				['autoQuality', 'Always use the best quality', 'Re-applies it when the player drops back to Auto.'],
				['showDuration', 'Show stream time', null],
				['pipButton', 'Picture in picture controls', null]
			] },
			{ title: 'Background tabs', items: [
				['inactiveQuality', 'Drop quality when hidden', null],
				['inactivePause', 'Pause the stream when hidden', null],
				['inactiveLoad', 'Start the stream in background tabs', 'Rooms opened in a new tab load muted instead of waiting for you.'],
				['errorQuality', 'Drop quality when the stream keeps stalling', 'One step down after repeated stalls; auto quality retries later.']
			] }
		] },
		{ id: 'look', label: 'Look', groups: [
			{ title: null, items: [
				['forceDark', 'Dark theme', null],
				['hideAds', 'Hide ads and banners', null],
				['hideSocials', 'Hide social links', null],
				['hideMerch', 'Hide merch links', null],
				['hideSurveys', 'Hide surveys and feedback prompts', null],
				['darkLegacy', 'Dark theme on the older pages too', 'Fan club, supporter, followers and account forms.'],
				['hidePlayerLogo', 'Hide logo on the player', null],
				['hideBadges', 'Hide thumbnail badges', null],
				['tightMargins', 'Tighter page margins', null],
				['cleanProfile', 'Flatten profile styling', 'Strips absolute positioning, backgrounds and animation from bios.']
			] },
			{ title: 'Who to show', items: [
				['hideGenderF', 'Hide women', null],
				['hideGenderM', 'Hide men', null],
				['hideGenderC', 'Hide couples', null],
				['hideGenderT', 'Hide trans', null]
			] }
		] },
		{ id: 'rooms', label: 'Rooms', groups: [
			{ title: null, items: [
				['bioInfo', 'Show extra room info', 'Country, region, time online, private and spy prices, fan club price, joined date.'],
				['cardTools', 'Menu on room cards', 'Hide cam, hide country, note, alert, add to multi cam.'],
				['cardWatchBtn', 'Add a watch-without-chat button', 'Puts a play button on every thumbnail.'],
				['openNewTab', 'Open rooms in a new tab', null],
				['randomLink', 'Random room link in the header', 'Uses the genders picked in the Multi tab.'],
				['autoChatRules', 'Accept room rules automatically', null],
				['trackSchedule', 'Remember when rooms are live', null],
				['time24', '24 hour times', null]
			] }
		] },
		{ id: 'chat', label: 'Chat', groups: [
			{ title: null, items: [
				['translateChat', 'Translate messages', null],
				['chatHideNotices', 'Hide room notices', null],
				['chatHideSubject', 'Hide subject changes', null],
				['chatHideTips', 'Hide tip messages', null],
				['chatHideGreys', 'Hide grey users', null],
				['chatTipsOnly', 'Tips only', 'Show only tip messages.'],
				['tipMarks', 'Tip marks on the timeline', 'A yellow tick under the scrubber where each tip landed, so you can rewind to it.']
			] }
		] },
		{ id: 'multi', label: 'Multi', groups: [
			{ title: null, items: [
				['multiCam', 'Enable multi cam', 'Opens in its own tab.'],
				['multiShowSubject', 'Show room subjects', null],
				['multiResizable', 'Resizable tiles', null],
				['multiHideOffline', 'Hide cams that are offline', null],
				['multiHidePrivate', 'Hide cams in private, away or password shows', null],
				['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: 'Panel', groups: [
			{ title: null, items: [
				['showLauncher', 'Show the floating button', null],
				['showStrip', 'Show the toolbar under the player', 'Zoom, picture, sound, record and room menus.'],
				['edgeSwipe', 'Open by swiping from the right edge', null],
				['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 === 'video') return selectHTML('cbx-quality-cap', 'Quality: ',
			[[0, 'best available'], [1080, 'up to 1080p'], [720, 'up to 720p'], [480, 'up to 480p']], S.qualityCap) +
			'<div class="cbx-mono" id="cbx-quality-note"></div>' +
			selectHTML('cbx-dvr-buffer', 'Rewind window: ',
			[[120, '2 minutes'], [300, '5 minutes'], [600, '10 minutes'], [1200, '20 minutes']], S.dvrBuffer) +
			selectHTML('cbx-mobile-h', 'Phone picture height: ',
			[['fit', 'whole frame (16:9)'], ['43', 'taller, 4:3 crop'], ['half', 'half the screen'], ['tall', 'most of the screen']], S.mobileDefaultH) +
			selectHTML('cbx-dvr-quality', 'Deep rewind quality: ',
			[[0, 'best available'], [1080, '1080p'], [720, '720p (recommended)'], [480, '480p'], [360, '360p']], S.dvrQuality) +
			'<div class="cbx-mono" id="cbx-dvr-note"></div>' +
			'<button class="cbx-b cbx-b-wide cbx-b-quiet" id="cbx-siteplayer">Back to site player (this page)</button>';

		if (id === 'look') return selectHTML('cbx-grid', 'Room 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) +
			'<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 selectHTML('cbx-tr-lang', 'Translate into ', LANGS, S.translateTo) +
			selectHTML('cbx-chat-font', 'Chat text: ', [[0, 'site default'], [12, 'small'], [14, 'medium'], [16, 'large'], [18, 'extra large']], S.chatFont) +
			'<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 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;

	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) + '</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);
		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-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-mobile-h') { S.mobileDefaultH = e.target.value; S.mobileHeightByRoom = {}; S.mobileHeight = 0; save(); if (isTouch()) fitTouchHeight(true); 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-dvr-buffer') {
				S.dvrBuffer = parseInt(e.target.value, 10); save();
				if (P.eng) { var ct = P.eng.active(); if (ct) fitBufferToMemory(ct); }
				return;
			}
			if (e.target.id === 'cbx-dvr-quality') {
				S.dvrQuality = parseInt(e.target.value, 10); save();
				var dq = $('video.cbx-dvr');
				if (dq && P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap()));
				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('https://chaturbate.com/?cbx-multi=1', '_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);
		$('#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(); ensureStrip();
			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 === 'deepPlayer') { setPlayerMode(S.deepPlayer ? 'deep' : 'off'); return; }
		if (k === 'bigBuffer' && P.eng) { var bt = P.eng.active(); if (bt) fitBufferToMemory(bt); }
		if (k === 'parkSite') parkSite(!!S.parkSite && !!P.armed);
		if (k === 'mobileFullQuality' && P.eng && P.armed) pinTrack(bestTrackUnder(dvrCap()));
		if (k === 'showHealth') updateBar();
		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 (stripEl) { stripEl.remove(); stripEl = null; } 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') placeLauncher();
		if (k === 'showStrip') ensureStrip();
		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 === 'rewindBar') { if (!S.rewindBar) removeDvr(); else playerTick(); }
		if (k === 'dvrMode') { if (S.dvrMode) installDvr(); else dropDvr(); }
		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 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 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 show = !!roomName() && S.showLauncher;
		dockEl.style.display = show ? 'inline-flex' : 'none';
		if (!show) return;
		updatePlayerToggle();
		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;
		dockEl.style.left = Math.round(left) + 'px';
		dockEl.style.top = Math.round(r.top + (r.height - h) / 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() {
		clearTimeout(idleTimer);
		var b = P.block; if (!b) return;
		var fs = document.fullscreenElement === b || document.webkitFullscreenElement === b;
		b.classList.remove('cbx-idle');
		if (!fs || !S.fsAutoHide) return;
		idleTimer = setTimeout(function () { if (P.block === b && !($('#cbx-scrub', b) || {})._held) b.classList.add('cbx-idle'); }, 3000);
	}
	['pointermove', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) {
		document.addEventListener(ev, function () { if (P.block && (document.fullscreenElement === P.block || document.webkitFullscreenElement === P.block)) 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();
			ensureStrip();
		}, 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();
	installBgMute();
	applyDark();
	applySiteCSS();

	onReady(function () {
		if (IS_MULTI) { buildMulti(); 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(); syncZoomClass(); }, 1000);
		(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(applyTouchClass, 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 });
	});
})();