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.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         Chaturbate Enhanced Plus
// @namespace    chaturbate.enhanced.plus
// @version      0.9.7
// @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,
		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();

	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';
			}
			out.uiGen = 4;
		} 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 dvrBudget() {
		var touch = document.documentElement.classList.contains('cbx-touch');
		return (touch ? (S.bigBuffer ? 150 : 60) : (S.bigBuffer ? 400 : 120)) * 1024 * 1024;
	}

	// the site keeps a hidden theater-mode copy with the same ids: take the one that has a size
	function siteVideo() {
		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,
				maxBufferLength: 30, maxMaxBufferLength: 60, liveSyncDurationCount: 2, maxLiveSyncPlaybackRate: 1,
				liveMaxLatencyDurationCount: Infinity, maxBufferHole: 1.5, nudgeMaxRetry: 10, 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); ring.bytes += buf.byteLength;
				while (ring.frags.length && ring.bytes > dvrBudget() / 2) ring.bytes -= ring.frags.shift().byteLength;
			});
		};
		return E;
	}

	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;
			var use = Math.max(60, Math.min(S.dvrBuffer, Math.floor(budget / (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 toggleCatchUp() { var v = activeVideo(); if (!v) return; v.playbackRate = v.playbackRate > 1 ? 1 : 2; toast(v.playbackRate > 1 ? 'Catching up at 2×' : 'Normal speed'); }
	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 "Keep buffer for saving" in the Record menu under the player, then reload the room', 5000); return; }
		if (!ring.frags.length) { toast('Nothing held yet'); return; }
		var parts = [];
		if (ring.ext === 'mp4') { if (!ring.init) { toast('No init segment captured yet; wait a moment'); return; } parts.push(ring.init); }
		parts = parts.concat(ring.frags);
		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');
	}

	/* ---- 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>'
	};
	function barHTML() {
		return '<div class="cbx-row cbx-row-scrub"><span id="cbx-scrub-at" class="cbx-time">live</span>' +
			'<div id="cbx-scrub" role="slider" title="Full = live. Drag left to rewind."><div class="cbx-held"></div><div class="cbx-thumb"></div></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>' +
			'<button data-act="snap" title="Save a frame (S)">' + I.cam + '</button>' +
			(S.clipSave ? '<button data-act="clip" title="Save the buffer (D)">' + I.save + '</button>' : '') +
			'<span id="cbx-behind"></span>' +
			'<button data-act="pip" title="Picture in picture">' + I.pip + '</button>' +
			'<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.box.appendChild(block);
		P.block = block; P.shell = shell; P.bar = bar;
		if (touch) { document.documentElement.classList.add('cep-touch-block'); fitTouchHeight(); window.addEventListener('resize', fitTouchHeight); wirePan(shell); }
		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 = 56; // thin strip
	function touchDefaultPic() { return Math.round(P.box.clientWidth * 9 / 16); }
	function touchMaxPic() { return Math.max(TOUCH_MIN_PIC, window.innerHeight - 80); }
	function fitTouchHeight() {
		var box = P.box, bar = P.bar; if (!box || !bar || !bar.isConnected) return;
		var grip = $('#cbx-hgrip', P.block);
		var def = touchDefaultPic();
		var pic = S.mobileHeight || def;
		pic = Math.max(TOUCH_MIN_PIC, Math.min(touchMaxPic(), pic));
		var h = pic + bar.offsetHeight + (grip ? grip.offsetHeight : 0);
		if (Math.abs(box.clientHeight - h) > 1) {
			box.style.height = h + 'px';
			growTouchAncestors(box);
			// the site places the panels below the player from its own measurements
			try { window.dispatchEvent(new Event('resize')); } catch (e) {}
		}
		// shorter than or at default: whole frame stays visible (letterbox).
		// taller than default: fill the box and crop (zoom), pan with left/right drags.
		var zoomed = pic > def + 1;
		P.block.classList.toggle('cbx-zoomed', zoomed);
		if (!zoomed && P.video && P.video.style.objectPosition) { P.panX = null; P.video.style.objectPosition = ''; }
	}
	// 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) {
		P.grown = P.grown || [];
		P.grown.forEach(function (n) { n.style.minHeight = ''; });
		var bottom = box.getBoundingClientRect().bottom, n = box;
		for (var i = 0; i < 8 && n.parentElement && n.parentElement !== document.body; i++) {
			n = n.parentElement;
			var pr = n.getBoundingClientRect();
			if (pr.bottom < bottom - 1) {
				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, p0 = 50, active = false;
		shell.addEventListener('pointerdown', function (e) { if (!P.block.classList.contains('cbx-zoomed')) return; x0 = e.clientX; p0 = P.panX == null ? 50 : P.panX; active = true; });
		shell.addEventListener('pointermove', function (e) {
			if (!active || !P.video) return;
			var dx = e.clientX - x0; if (Math.abs(dx) < 6) return;
			P.panX = Math.max(0, Math.min(100, p0 - dx / Math.max(1, shell.clientWidth) * 100));
			P.video.style.objectPosition = P.panX + '% 50%';
		});
		['pointerup', 'pointercancel', 'pointerleave'].forEach(function (ev) { shell.addEventListener(ev, function () { active = false; }); });
	}
	function removeBlock() {
		if (P.block) {
			// our panel may have been re-homed into the block for fullscreen; get it out first
			[scrimEl, panelEl, launcherEl, dockEl, ddEl, $('#cbx-toast')].forEach(function (n) { if (n && P.block.contains(n)) document.body.appendChild(n); });
			try { P.block.remove(); } catch (e) {}
		}
		if (P.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;
			S.mobileHeight = pic;
			fitTouchHeight();
		});
		['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 () { S.mobileHeight = 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();
			({ pause: togglePause, mute: toggleMute, back: function () { seekBack(10); }, fwd: function () { seekForward(10); },
				live: goLive, snap: snapshotFrame, clip: saveClip, pip: togglePiP, fs: goFullscreen })[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); });
		['pointerup', 'pointercancel'].forEach(function (ev) { scrub.addEventListener(ev, function () { scrub._held = false; }); });
		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) {
			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);
		});
	}
	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 out = $('#cbx-behind', bar), scrub = $('#cbx-scrub', bar), at = $('#cbx-scrub-at', bar);
		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));
		$('.cbx-held', scrub).style.width = Math.min(100, (span / win) * 100).toFixed(1) + '%';
		$('.cbx-thumb', scrub).style.left = (pos * 100).toFixed(2) + '%';
		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);
		out.textContent = warming ? 'Rewind ready in ' + Math.max(0, Math.ceil(10 - span)) + 's…'
			: (v.playbackRate > 1 ? '2× · ' : '') + (behind < 2 ? fmtTime(span) + (atMax ? ' (max)' : ' / ' + fmtTime(win)) + ' held' + mb : '−' + fmtTime(behind) + ' of ' + fmtTime(span) + (atMax ? ' (max)' : ''));
		out.title = atMax && P.heldNote ? P.heldNote : '';
		bar.classList.toggle('cbx-behind-live', behind >= 2);
		if (v.playbackRate > 1 && behind < 1.5) v.playbackRate = 1;
		$('button[data-act="pause"]', bar).innerHTML = v.paused ? I.play : I.pause;
		$('button[data-act="mute"]', bar).innerHTML = (v.muted || v.volume === 0) ? I.muted : I.vol;
		var vol = $('#cbx-vol', bar);
		if (document.activeElement !== vol) vol.value = v.muted ? 0 : Math.round(v.volume * 100);
		var qsel = $('#cbx-qsel', bar);
		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) {} });
				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; });
	}
	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);
	}
	function parkSite(on) {
		var site = P.site && P.site.isConnected ? P.site : null; if (!site) return;
		try {
			if (on) { if (!site.paused) { site.pause(); log('site player parked'); } }
			else if (site.paused && P.video) { var pp = site.play(); if (pp && pp.catch) pp.catch(function () {}); }
		} 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(S.dvrQuality || S.qualityCap));
				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;
		var wd = setInterval(function () {
			if (v !== P.video || !v.isConnected) { clearInterval(wd); 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.paused && ticks % 5 === 0) parkSite(true);
			if (v.paused || document.hidden) return;
			var end = v.buffered.length ? v.buffered.end(v.buffered.length - 1) : 0;
			if (end <= lastEnd + 0.2 && end - v.currentTime < 3) stuck++; else stuck = 0;
			lastEnd = end;
			if (stuck === 3 && P.eng) { log('buffer end stalled, nudging'); P.eng.retry(); }
			if (stuck >= 6 && P.eng && !unlocked) { unlocked = true; log('pinned rendition starving, back to auto'); P.eng.auto(); toast('This rendition stalled — quality set to auto'); }
			if (stuck === 0) unlocked = false;
		}, 2000);
	}
	function dropDvr() {
		var v = P.video, E = P.eng;
		P.video = null; P.eng = null; P.armed = false; P.ring = null;
		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) {
			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();
		dockEl.innerHTML = '<i class="cbx-dot cbx-dot-' + (deep ? 'deep' : 'off') + '"></i>' + (deep ? 'Deep' : 'Site');
		dockEl.title = 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;
			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× · 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 '>': case '.': toggleCatchUp(); 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;
		});
	}

	function pumpQueue() {
		if (trBusy || !trQueue.length) return;
		trBusy = true;
		var job = trQueue.shift();
		translate(job.text).then(function (res) {
			if (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;
			var tag = el('span', { 'class': 'cbx-tr' }, esc(res.text));
			job.node.appendChild(tag);
		}).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);
		});
	}

	function translateNewMessages() {
		if (!S.translateChat) return;
		$$('div[data-testid="chat-message"]').forEach(function (m) {
			if (m._cbxTr) 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-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-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}#cbx-block.cbx-zoomed #cbx-shell{touch-action:pan-y}' +
		'#cbx-block:fullscreen{position:fixed;inset:0}' +
		'#cbx-bar{flex:none;display:flex;flex-direction:column;gap:0;padding:2px 6px 3px;background:#14171a;border-top:1px solid #2a3138}' +
		'.cbx-row{display:flex!important;align-items:center;gap:3px;flex-wrap:nowrap;min-width:0;width:100%;box-sizing:border-box}' +
		'.cbx-row-scrub{gap:6px;min-height: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-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 6px 6px 0;background:rgba(255,255,255,.18);cursor:pointer;touch-action:none;user-select:none;display:block!important}' +
		'#cbx-scrub .cbx-held{position:absolute;right: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-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,#cbx-bar button[data-act="pip"]{display:none}' +
		'.cbx-row-btns{flex-wrap:nowrap;justify-content:center;gap:0}.cbx-row-scrub{gap:6px}' +
		'#cbx-bar button{min-width:0;padding:0 2px;flex:0 0 auto;width:38px}#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:10px 8px}html.cbx-touch #cbx-scrub .cbx-thumb{width:18px;height:18px;margin:-9px 0 0 -9px}' +
		'#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}';

	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]; });
		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();
		$$(CARD_SEL).forEach(function (card) {
			var user = cardUser(card);
			if (!user) return;

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

			tools.addEventListener('click', function (e) {
				var b = e.target.closest('button');
				if (!b) return;
				e.preventDefault(); e.stopPropagation();
				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 });
			});
		});
	}

	// 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).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.destroy(); v._cbxHls = null; } 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; }
			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()) {
			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;
				if (opts) for (var k in opts) if (k !== 'onFatal') 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);
				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')) {
				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);
		clearInterval(rec.timer); rec.timer = setInterval(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() {
		clearInterval(rec.timer); 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-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 '<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 === '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) +
			'<h3>Rewind buffer</h3>' +
			rowHTML('clipSave', 'Keep buffer for saving', 'Adds a save button to the player that writes the held window to a file instantly. 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 '';
	}

	function openDD(id, anchor, ctx) {
		if (!ddEl) {
			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.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(); });
		window.addEventListener('scroll', function () { if (ddOpen) 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 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;
		document.head.appendChild(el('style', { id: 'cbx-strip-css' }, STRIP_CSS));
		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 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 (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-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;max-height:80vh;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{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', 'Pause the hidden site player', 'While deep rewind runs, keeps the site\'s own player paused so it stops fetching video underneath ours.'],
				['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.']
			] },
			{ 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]
			] }
		] },
		{ 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-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) +
			'<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">0.9.7</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-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(S.dvrQuality || S.qualityCap));
				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 === 'clipSave') { removeBlock(); if (S.clipSave) toast('Reload the room once so the buffer is captured from the start'); }
		if (k === 'tipSliderZero' && S.tipSliderZero) { tipDone = false; applyTipMute(0); }
		if (k === 'bgMute' && !S.bgMute) applyBgMute(false);
		if (k === 'showLauncher') 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() {
		clearInterval(durTimer);
		var old = $('#cbx-duration');
		if (!S.showDuration || !roomName()) { if (old) old.remove(); return; }
		durTimer = setInterval(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 (getComputedStyle(host).position === 'static') host.style.position = 'relative';
			var badge = $('#cbx-duration');
			if (!badge) { badge = el('div', { id: 'cbx-duration' }); host.appendChild(badge); }
			badge.textContent = fmtTime(v.currentTime);
		}, 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);
		if (p && typeof p.x === 'number') {
			launcherEl.style.left = Math.min(Math.max(p.x, 4), innerWidth - 44) + 'px';
			launcherEl.style.top = Math.min(Math.max(p.y, 4), innerHeight - 44) + 'px';
			launcherEl.style.right = 'auto'; launcherEl.style.bottom = 'auto';
		} else {
			launcherEl.style.right = '12px'; launcherEl.style.bottom = '12px';
		}
		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); });
	});
	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;
	function scheduleWork() {
		clearTimeout(tick);
		tick = setTimeout(function () {
			decorateCards();
			ensureRandomLink();
			autoAcceptRules();
			translateNewMessages();
			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 0.9.7 loaded on ' + location.pathname); } catch (e) {}

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

	onReady(function () {
		if (IS_MULTI) { buildMulti(); return; }
		if ('ontouchstart' in window) document.documentElement.classList.add('cbx-touch');
		applySiteCSS();
		buildUI();
		installHoverPreview();
		hookHistory();
		installDuration();
		decorateCards();
		renderBioInfo();
		installExclusiveAudio();
		installErrorQuality();
		installInactiveLoad();
		startAlerts();
		startQualityWatchdog();
		setTimeout(applyQuality, 2500);
		playerTick();
		applyTipMute(0);
		(function pump() {
			var scrub = $('#cbx-scrub');
			if (!document.hidden) { try { updateBar(); } catch (e) {} }
			setTimeout(pump, scrub && scrub._held ? 250 : 1000);
		})();
		installKeys();
		new MutationObserver(scheduleWork).observe(document.body, { childList: true, subtree: true });
	});
})();