Pornhub Pro-ish

Adds a menu with to apply filters, add sorting, player tweaks like hiding the cursor and auto-mute, and site-wide quality-of-life improvements like automatic age verification and always default to the english version of the site. Beta download button added! Actively maintained and used by me so updated regularly. Feel free to request features!

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Pornhub Pro-ish
// @namespace    https://www.reddit.com/user/Alpacinator
// @version      8.1.0
// @include      *://*.pornhub.com/*
// @grant        none
// @run-at       document-start
// @description  Adds a menu with to apply filters, add sorting, player tweaks like hiding the cursor and auto-mute, and site-wide quality-of-life improvements like automatic age verification and always default to the english version of the site. Beta download button added! Actively maintained and used by me so updated regularly. Feel free to request features!
// ==UserScript==

// @name         Pornhub Pro-ish

// @namespace    https://www.reddit.com/user/Alpacinator


// @include      *://*.pornhub.com/*

// @grant        none

// @run-at       document-start

// @description  Adds a menu with to apply filters, add sorting, player tweaks like hiding the cursor and auto-mute, and site-wide quality-of-life improvements like automatic age verification and always default to the english version of the site. Beta download button added! Actively maintained and used by me so updated regularly. Feel free to request features!



// ==/UserScript==



(function() {
	'use strict';

	// Guard against double injection. Mobile userscript managers can run this
	// twice, which would give two App instances with racing caches.
	if (window.__phproLoaded) return;
	window.__phproLoaded = true;

	// Cookie-backed storage shared across subdomains, mirrored to localStorage.
	// The cookie is the cross-subdomain channel; the mirror is the durable copy,
	// since Safari expires JS-written cookies after 7 days regardless of max-age.
	// Writes also touch a ping key so other tabs get a 'storage' event.
	const CrossDomainStorage = (() => {
		// Root domain with a leading dot, required for cross-subdomain cookies.
		// slice(-2) assumes a single-label TLD and would need updating for .co.uk.
		const _rootDomain = (() => {
			const parts = window.location.hostname.split('.');
			return '.' + parts.slice(-2).join('.');
		})();

		const COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60; // 10 years

		// Written on every setItem purely so other tabs get a 'storage' event.
		const SYNC_PING_KEY = 'phpro_sync_ping';

		// Writes one root-domain cookie. A pair over ~4096 bytes is dropped silently,
		// so oversized values are rejected here and left to the localStorage mirror.
		function _writeCookie(key, value) {
			try {
				const encoded = encodeURIComponent(String(value));
				if (key.length + encoded.length > 4000) return false;
				document.cookie = [
					`${key}=${encoded}`,
					`domain=${_rootDomain}`,
					'path=/',
					`max-age=${COOKIE_MAX_AGE}`,
					'Secure',
					'SameSite=Lax',
				].join('; ');
				return true;
			} catch (err) {
				return false;
			}
		}

		function _readAllCookies() {
			const map = {};
			document.cookie.split('; ').forEach(pair => {
				const idx = pair.indexOf('=');
				if (idx < 0) return;
				const k = pair.slice(0, idx).trim();
				const v = pair.slice(idx + 1).trim();
				if (k) map[k] = v;
			});
			return map;
		}

		return {
			// Cookie first, then the localStorage mirror. The mirror is never deleted:
			// it is the only copy that survives Safari's 7 day cap on JS-written cookies.
			getItem(key) {
				try {
					const cookies = _readAllCookies();
					if (Object.prototype.hasOwnProperty.call(cookies, key)) {
						const value = decodeURIComponent(cookies[key]);
						// Keep the mirror in step in case the cookie was written
						// by an older version of the script or another tab.
						try {
							if (localStorage.getItem(key) !== value) {
								localStorage.setItem(key, value);
							}
						} catch (_) {}
						return value;
					}
					// Cookie missing or expired. Fall back to the durable mirror
					// and restore the cookie so other subdomains see it again.
					const lsVal = localStorage.getItem(key);
					if (lsVal !== null) {
						_writeCookie(key, lsVal);
						return lsVal;
					}
					return null;
				} catch (err) {
					try {
						return localStorage.getItem(key);
					} catch (_) {
						return null;
					}
				}
			},

			// Writes to both the cookie and the mirror, then touches SYNC_PING_KEY so
			// other tabs on this origin get a 'storage' event. Cookie writes fire none.
			setItem(key, value) {
				const str = String(value);
				_writeCookie(key, str);
				try {
					localStorage.setItem(key, str);
				} catch (_) {}
				try {
					localStorage.setItem(SYNC_PING_KEY, `${key}:${Date.now()}`);
				} catch (_) {}
			},

			// Key other tabs watch to know something changed. Holds no real
			// data, it exists purely to trigger the 'storage' event.
			SYNC_PING_KEY,

			// Expires the cookie on both root domain and current subdomain,
			// then also removes any localStorage entry for the same key.
			removeItem(key) {
				try {
					document.cookie = [
						`${key}=`,
						`domain=${_rootDomain}`,
						'path=/',
						'max-age=0',
						'SameSite=Lax',
					].join('; ');
					// Also clear any plain subdomain cookie that may have existed
					document.cookie = `${key}=; path=/; max-age=0; SameSite=Lax`;
				} catch (err) {}
				try {
					localStorage.removeItem(key);
					localStorage.setItem(SYNC_PING_KEY, `${key}:${Date.now()}`);
				} catch (_) {}
			},
		};
	})();

	// Sets the age-verification cookie at document-start so the disclaimer never
	// appears, and reloads once if the page was already served with the gate.
	class AgeGate {
		static _COOKIE_KEY = 'accessAgeDisclaimerPH';
		static _COOKIE_VALUE = '2';
		static _COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60; // 10 years in seconds

		// Whether the cookie was absent at document-start, meaning the server served
		// the age gate page and a reload is needed once the cookie is set.
		static _servedWithoutCookie = false;
		static _initialCheckDone = false;

		static _CONSENT_KEY = 'cookieConsent';
		static _CONSENT_VALUE = '3';

		static set() {
			const parts = window.location.hostname.split('.');
			const rootDomain = '.' + parts.slice(-2).join('.');

			const base = [
				`${AgeGate._COOKIE_KEY}=${AgeGate._COOKIE_VALUE}`,
				'path=/',
				`max-age=${AgeGate._COOKIE_MAX_AGE}`,
				'SameSite=Lax',
			];

			document.cookie = [...base, `domain=${rootDomain}`].join('; ');
			document.cookie = base.join('; ');

			// Set cookieConsent=2 if missing or currently 1 (partial/rejected consent).
			const existingConsent = document.cookie
				.split('; ')
				.find(c => c.startsWith(`${AgeGate._CONSENT_KEY}=`))
				?.split('=')[1];
			if (existingConsent === undefined || existingConsent === '1') {
				const consentBase = [
					`${AgeGate._CONSENT_KEY}=${AgeGate._CONSENT_VALUE}`,
					'path=/',
					`max-age=${AgeGate._COOKIE_MAX_AGE}`,
					'SameSite=Lax',
				];
				document.cookie = [...consentBase, `domain=${rootDomain}`].join('; ');
				document.cookie = consentBase.join('; ');
				console.log(`AgeGate: ${AgeGate._CONSENT_KEY} set to ${AgeGate._CONSENT_VALUE} (was: ${existingConsent ?? 'absent'})`);
			}

			console.log('AgeGate: cookie set');
			return AgeGate.exists();
		}

		// Returns true if the age-gate cookie is already present.
		static exists() {
			return document.cookie
				.split('; ')
				.some(c => c.startsWith(`${AgeGate._COOKIE_KEY}=`));
		}

		// Runs at document-start. Captures the initial cookie state and sets the
		// cookie; the reload is deferred to reloadIfNeeded(), where it is reliable.
		static run() {
			// Captured before the enabled check: at document-start the setting cookie may
			// not exist yet, so gating this behind it would miss the capture entirely.
			if (!AgeGate._initialCheckDone) {
				AgeGate._initialCheckDone = true;
				AgeGate._servedWithoutCookie = !AgeGate.exists();
			}

			// Treat a missing setting as enabled, since the feature default is true.
			// Only an explicit 'false' disables it.
			const enabled = CrossDomainStorage.getItem('autoConfirmAgeState') !== 'false';
			if (!enabled) return;

			if (!AgeGate.exists()) {
				AgeGate.set();
			}
		}

		// Reloads once if the gate was served. A sessionStorage flag stops a loop.
		static reloadIfNeeded() {
			// Match run()'s semantics: missing setting counts as enabled (default true).
			const enabled = CrossDomainStorage.getItem('autoConfirmAgeState') !== 'false';
			if (!enabled) return;

			if (!AgeGate._servedWithoutCookie) {
				// Page was served with the cookie already present - no gate, no reload.
				sessionStorage.removeItem('ageGateReloaded');
				return;
			}

			// Make sure the cookie is actually set before reloading.
			if (!AgeGate.exists() && !AgeGate.set()) {
				console.warn('AgeGate: cookie could not be set; skipping reload');
				return;
			}

			if (!sessionStorage.getItem('ageGateReloaded')) {
				sessionStorage.setItem('ageGateReloaded', 'true');
				console.log('AgeGate: reloading to clear the age gate');
				location.reload();
			} else {
				console.warn('AgeGate: already reloaded once this session; not reloading again');
			}
		}

		// Removes the age-gate cookie and reloads so the disclaimer reappears.
		// Called when the user disables "Auto-confirm age" in the menu.
		static clear() {
			const parts = window.location.hostname.split('.');
			const rootDomain = '.' + parts.slice(-2).join('.');
			// Expire on both the root domain and the bare host to fully clear it.
			document.cookie = `${AgeGate._COOKIE_KEY}=; domain=${rootDomain}; path=/; max-age=0; SameSite=Lax`;
			document.cookie = `${AgeGate._COOKIE_KEY}=; path=/; max-age=0; SameSite=Lax`;
			console.log('AgeGate: cookie cleared');
			sessionStorage.removeItem('ageGateReloaded');
			location.reload();
		}
	}
	AgeGate.run();

	// Central place for magic numbers, selectors, and named constants.
	const CONFIG = {
		SCRIPT_NAME: 'PH-PRO',
		// Three distinct values so the hover effect is visible while transparency
		// is enabled (TRANSPARENT < HOVER < DEFAULT).
		OPACITY: {
			TRANSPARENT: 0.70,
			HOVER: 0.85,
			DEFAULT: 1.0,
		},
		BUTTON_BG: {
			TRANSPARENT: 'rgba(0,0,0,0.45)',
			SOLID: 'black',
		},
		TIMING: {
			MUTATION_DEBOUNCE_MS: 300,
			LANGUAGE_CHECK_DELAY_MS: 1000,
			CURSOR_HIDE_DELAY_S: 3,
			AUTOSCROLL_MIN_DELAY_MS: 800,
			AUTOSCROLL_MAX_DELAY_MS: 2500,
			AUTOSCROLL_MAX_CONSECUTIVE_EMPTY: 3,
			BUTTON_FLASH_MS: 100,
			OBSERVER_THROTTLE_MS: 1000,
			FEATURE_INIT_DELAY_MS: 100,
			// How long after a tap or keypress a 'volumechange' still counts as
			// user-initiated and is therefore worth persisting.
			VOLUME_GESTURE_WINDOW_MS: 1500,
			SLIDE_MS: 280, // menu panel slide-in/out animation duration
			TOUCH: {
				// px from the screen edge that qualifies as an edge-swipe gesture
				EDGE_THRESHOLD_PX: 30,
				// minimum horizontal travel (px) before a swipe is recognised
				SWIPE_MIN_PX: 60,
			},
			ELEMENT_HIDE_LOAD_DELAY_MS: 500,
			DOWNLOAD_BUTTON_DELAY_MS: 4000,
			BUTTON_FADE_DELAY_MS: 5000,  // ms after page load before menu button fades (swipe-to-open only)
			QUICK_MENU_FADE_DELAY_MS: 2000, // ms after the cursor leaves before the quick menu fades
		},
		LIMITS: {
			// Maximum length of the combined comma-separated filter words string
			// stored in a cookie. Keeps the cookie under typical 4KB cookie limits.
			FILTER_WORDS_MAX_LENGTH: 255,
		},
		SORT: {
			VALID_MODES: ['none', 'duration', 'award', 'views'],
		},
		SELECTORS: {
			VIDEO_LISTS: 'ul.videos, ul.videoList',
			WATCHED_INDICATORS: '.watchedVideoText, .watchedVideo',
			PAID_CONTENT: 'span.price, .premiumicon, img.privateOverlay',
			VR_INDICATOR: 'span.vr-thumbnail',
			SHORTS_SECTION: '#shortiesListSection',
			// Deliberately not the generic 'video' tag: hover-preview thumbnails on
			// listing pages are plain <video> elements too.
			VIDEO_ELEMENT: '.mgp_videoElement',
			LANGUAGE_DROPDOWN: 'li.languageDropdown',
			ENGLISH_OPTION: 'li[data-lang="en"] a.networkTab',
			FULLSCREEN_BUTTON: '.mgp_fullscreen',
			DOWNLOAD_BUTTON_ID: 'phpro-download-btn',
			PLAYLIST_CONTAINERS: [
				'#videoPlaylist',
				'#videoPlaylistSection',
				'#playListSection',
				'[id*="playlist"]',
				'[class*="playlist"]',
				'[data-context="playlist"]',
			],
			ELEMENTS_TO_HIDE: [
				'#countryRedirectMessage',
				'#js-abContainterMain',
				'#welcome',
				'div.pornInLangWrapper',
				'#loadMoreRelatedVideosCenter',
				'[data-label="recommended_load_more"]',
				'.buttonClass.blackBtn.eudsaLink',
        '#cookieBanner',
        '.cbShort',
			],
		},
	};

	// Centralised logger. Prefixes the script name and the calling context.
	function handleError(context, error, level = 'error') {
		const message = error instanceof Error ? error.message : String(error);
		const prefix = `${CONFIG.SCRIPT_NAME} [${context}]:`;
		if (level === 'warn') {
			console.warn(prefix, message, error);
		} else {
			console.error(prefix, message, error);
		}
	}

	// Stateless helpers shared across the script.
	const Utils = {
		// Prefixed console wrapper. Use instead of bare console.log so all
		// script output is groupable and filterable in DevTools.
		log(message, level = 'info') {
			const prefix = `${CONFIG.SCRIPT_NAME}:`;
			if (level === 'error') console.error(prefix, message);
			else if (level === 'warn') console.warn(prefix, message);
			else console.log(prefix, message);
		},

		// Returns a version of `func` that delays execution until `wait` ms have
		// passed since the last call. Useful for batching rapid DOM mutations.
		debounce(func, wait) {
			let timeout;
			return function(...args) {
				clearTimeout(timeout);
				timeout = setTimeout(() => func.apply(this, args), wait);
			};
		},

		throttle(func, limit) {
			let inThrottle = false;
			return function(...args) {
				if (!inThrottle) {
					func.apply(this, args);
					inThrottle = true;
					setTimeout(() => {
						inThrottle = false;
					}, limit);
				}
			};
		},

		// Converts "HH:MM:SS" or "MM:SS" to total seconds.
		// Returns 0 for invalid input so sorting still works gracefully.
		parseDuration(durationString) {
			if (!durationString || typeof durationString !== 'string') return 0;
			const parts = durationString.trim().split(':').map(Number);
			return parts.reduce((acc, part) => (isNaN(part) ? acc : acc * 60 + part), 0);
		},

		createElement(tag, options = {}) {
			const element = document.createElement(tag);
			for (const [key, value] of Object.entries(options)) {
				try {
					if (key === 'style' && typeof value === 'object') {
						Object.assign(element.style, value);
					} else if (key === 'textContent') {
						element.textContent = value;
					} else if (key === 'className') {
						element.className = value;
					} else if (key === 'dataset' && typeof value === 'object') {
						Object.assign(element.dataset, value);
					} else {
						element.setAttribute(key, value);
					}
				} catch (err) {
					handleError(`createElement(${tag}).${key}`, err, 'warn');
				}
			}
			return element;
		},

		// querySelector with try/catch - returns null instead of throwing on
		// invalid selectors (which can happen if the site changes its markup).
		safeQuerySelector(selector, context = document) {
			try {
				return context.querySelector(selector);
			} catch (err) {
				handleError(`safeQuerySelector("${selector}")`, err, 'warn');
				return null;
			}
		},

		// querySelectorAll with try/catch - returns [] instead of throwing.
		safeQuerySelectorAll(selector, context = document) {
			try {
				return Array.from(context.querySelectorAll(selector));
			} catch (err) {
				handleError(`safeQuerySelectorAll("${selector}")`, err, 'warn');
				return [];
			}
		},

		sanitizeFilterWords(input) {
			if (!input || typeof input !== 'string') return [];
			const clamped = input.slice(0, CONFIG.LIMITS.FILTER_WORDS_MAX_LENGTH);
			return clamped
				.split(',')
				.map(w => w.trim().toLowerCase())
				.filter(w => w.length >= 1);
		},

		// Validated against CONFIG.SORT.VALID_MODES. Migrates the legacy 'trophy'
		// value, which was renamed to 'award'.
		getValidSortMode() {
			let raw = CrossDomainStorage.getItem('sortModeState') ?? 'none';
			if (raw === 'trophy') {
				raw = 'award';
				CrossDomainStorage.setItem('sortModeState', raw);
			}
			return CONFIG.SORT.VALID_MODES.includes(raw) ? raw : 'none';
		},

		// Injects a <style> tag into <head> and returns the element.
		addStylesheet(css) {
			const style = Utils.createElement('style', {
				textContent: css
			});
			document.head.appendChild(style);
			return style;
		},
	};

	// Minimal pub/sub used to decouple the feature classes from each other.
	class EventEmitter {
		constructor() {
			this._events = new Map();
		}

		on(event, callback) {
			if (!this._events.has(event)) this._events.set(event, []);
			this._events.get(event).push(callback);
		}

		off(event, callback) {
			if (!this._events.has(event)) return;
			this._events.set(event, this._events.get(event).filter(cb => cb !== callback));
		}

		emit(event, data) {
			if (!this._events.has(event)) return;
			for (const callback of this._events.get(event)) {
				try {
					callback(data);
				} catch (err) {
					handleError(`EventEmitter.emit("${event}")`, err);
				}
			}
		}

		removeAllListeners() {
			this._events.clear();
		}
	}

	// Single source of truth for boolean feature flags. Wraps CrossDomainStorage
	// with an in-memory cache and emits 'stateChanged' on every change.
	class StateManager {
		constructor(eventEmitter) {
			this._cache = new Map();
			this._eventEmitter = eventEmitter;
			this._validators = new Map();
		}

		addValidator(key, validator) {
			this._validators.set(key, validator);
		}

		// Deliberately does not write the default back on a read miss. It used to,
		// which turned an expired cookie into a permanent overwrite broadcast to
		// every other tab. Only set() should ever persist anything.
		get(key, defaultValue = false) {
			if (this._cache.has(key)) return this._cache.get(key);

			try {
				const raw = CrossDomainStorage.getItem(key);
				const value = raw !== null ? raw === 'true' : defaultValue;
				const validated = this._validate(key, value, defaultValue);
				this._cache.set(key, validated);
				return validated;
			} catch (err) {
				handleError(`StateManager.get("${key}")`, err);
				return defaultValue;
			}
		}

		// Write a boolean setting. Validates, updates storage and cache, then
		// emits 'stateChanged' if the value actually changed.
		set(key, value, emit = true) {
			try {
				const validated = this._validate(key, value, value);
				if (validated !== value) {
					Utils.log(`StateManager: invalid value for "${key}": ${value}`, 'warn');
					return false;
				}
				const oldValue = this._cache.get(key);
				this._persist(key, value);
				this._cache.set(key, value);
				if (emit && oldValue !== value) {
					this._eventEmitter.emit('stateChanged', {
						key,
						oldValue,
						newValue: value
					});
				}
				return true;
			} catch (err) {
				handleError(`StateManager.set("${key}")`, err);
				return false;
			}
		}

		// Flips a boolean setting and returns the new value.
		toggle(key) {
			const newValue = !this.get(key);
			this.set(key, newValue);
			return newValue;
		}

		// Drops the in-memory cache so the next get() re-reads from storage.
		// Called when the tab becomes visible again in case another tab changed a setting.
		clearCache() {
			this._cache.clear();
		}

		_validate(key, value, fallback) {
			const validator = this._validators.get(key);
			if (!validator) return value;
			return validator(value) ? value : fallback;
		}

		_persist(key, value) {
			CrossDomainStorage.setItem(key, String(value));
		}
	}

	// Data descriptor for a single menu toggle.
	class Feature {
		constructor({
			label,
			key,
			handler,
			id,
			defaultState = false,
			category = 'general',
		}) {
			this.label = label;
			this.key = key;
			this.handler = handler || (() => {});
			this.id = id;
			this.defaultState = defaultState;
			this.category = category;
		}
	}

	// Loads every page of a playlist by fetching the site's internal API and
	// appending results, instead of clicking "Load more" repeatedly.
	class AutoScroller {
		constructor(eventEmitter) {
			this._eventEmitter = eventEmitter;
			this.isRunning = false;
			this._timeoutId = null;
			this._playlistPage = null;
			this._fetchedPages = null;
		}

		// Starts autoscrolling. Calculates the starting page from the number of
		// <li> items already on screen (each page holds 32 items).
		start() {
			if (this.isRunning) {
				Utils.log('AutoScroll already running');
				return false;
			}
			this.isRunning = true;
			this._playlistPage = Math.floor(
				document.querySelectorAll('ul.videos.row-5-thumbs li').length / 32
			) + 1;
			this._fetchedPages = new Set();
			this._consecutiveEmpty = 0;
			this._retriedCurrentPage = false;
			Utils.log('AutoScroll started');
			this._eventEmitter.emit('autoscrollStateChanged', {
				isRunning: true
			});
			this._scheduleNext(0);
			return true;
		}

		// Stops autoscrolling and clears the pending timeout.
		stop() {
			if (!this.isRunning) return false;
			this.isRunning = false;
			if (this._timeoutId) {
				clearTimeout(this._timeoutId);
				this._timeoutId = null;
			}
			Utils.log('AutoScroll stopped');
			this._eventEmitter.emit('autoscrollStateChanged', {
				isRunning: false
			});
			return true;
		}

		// Convenience method used by the menu button.
		toggle() {
			return this.isRunning ? this.stop() : this.start();
		}

		_scheduleNext(delayMs) {
			this._timeoutId = setTimeout(() => this._scrollLoop(), delayMs);
		}

		// Fetches one page (~32 videos) and appends genuinely new items. The site's
		// pagination can return videos already on screen, so ids are deduped against
		// the DOM before anything is appended.
		async _scrollLoop() {
			if (!this.isRunning) return;

			try {
				if (this._fetchedPages.has(this._playlistPage)) {
					Utils.log(`AutoScroll: page ${this._playlistPage} already fetched, skipping`);
					this._playlistPage++;
					this._scheduleNext(0);
					return;
				}

				this._fetchedPages.add(this._playlistPage);

				const id =
					document.querySelector('[data-playlist-id]')?.dataset.playlistId ??
					location.pathname.match(/\/(\d+)$/)?.[1];
				const token = document.querySelector('[data-token]')?.dataset.token;

				const response = await fetch(
					`${location.origin}/playlist/viewChunked?id=${id}&token=${token}&page=${this._playlistPage}`, {
						credentials: 'include',
						headers: {
							'X-Requested-With': 'XMLHttpRequest',
							'Sec-Fetch-Site': 'same-origin',
						},
						method: 'GET',
						mode: 'cors',
					}
				);

				const html = await response.text();
				const list = document.querySelector('ul.videos.row-5-thumbs');

				if (!list) {
					Utils.log('AutoScroll: video list element not found, stopping');
					this.stop();
					return;
				}

				for (const li of list.querySelectorAll('li')) {
					li.style.display = '';
				}

				// Build the id Set and drop any duplicates already in the DOM.
				const existingIds = new Set();
				for (const li of list.querySelectorAll('li[data-video-id]')) {
					const vid = li.dataset.videoId;
					if (existingIds.has(vid)) {
						li.remove();
						Utils.log(`AutoScroll: removed pre-existing duplicate ${vid}`);
					} else {
						existingIds.add(vid);
					}
				}

				const template = document.createElement('template');
				template.innerHTML = html;
				const incoming = Array.from(template.content.querySelectorAll('li[data-video-id]'));
				const duplicates = incoming.filter(li => existingIds.has(li.dataset.videoId)).length;
				if (duplicates > 0) Utils.log(`AutoScroll: skipped ${duplicates} duplicate(s)`);

				const countBefore = list.querySelectorAll('li.pcVideoListItem').length;
				// Track the first item we actually append so we can scroll to it
				// (rather than to a duplicate that was filtered out).
				let firstAppended = null;
				for (const li of incoming) {
					if (!existingIds.has(li.dataset.videoId)) {
						list.appendChild(li);
						if (!firstAppended) firstAppended = li;
					}
				}
				const countAfter = list.querySelectorAll('li.pcVideoListItem').length;

				firstAppended?.scrollIntoView({
					behavior: 'smooth',
					block: 'start'
				});
				this._playlistPage++;

				if (countAfter <= countBefore) {
					if (!this._retriedCurrentPage) {
						// First empty result for this page - undo the page increment and
						// retry once before counting it as a genuinely empty response.
						this._retriedCurrentPage = true;
						this._playlistPage--;
						this._fetchedPages.delete(this._playlistPage);
						Utils.log(`AutoScroll: no new items on page ${this._playlistPage + 1}, retrying once`);
						const {
							AUTOSCROLL_MIN_DELAY_MS: min,
							AUTOSCROLL_MAX_DELAY_MS: max
						} = CONFIG.TIMING;
						this._scheduleNext(min + Math.floor(Math.random() * (max - min)));
						return;
					}
					// Already retried - count it as empty and move on.
					this._retriedCurrentPage = false;
					this._consecutiveEmpty++;
					Utils.log(`AutoScroll: no new items after retry (${this._consecutiveEmpty}/${CONFIG.TIMING.AUTOSCROLL_MAX_CONSECUTIVE_EMPTY})`);
					if (this._consecutiveEmpty >= CONFIG.TIMING.AUTOSCROLL_MAX_CONSECUTIVE_EMPTY) {
						Utils.log('AutoScroll: max consecutive empty responses reached, stopping');
						this.stop();
						return;
					}
				} else {
					this._consecutiveEmpty = 0;
					this._retriedCurrentPage = false;
				}
			} catch (err) {
				handleError('AutoScroller._scrollLoop', err);
				const list = document.querySelector('ul.videos.row-5-thumbs');
				const lastLi = list?.querySelector('li:last-child');
				if (lastLi) lastLi.scrollIntoView({
					behavior: 'smooth',
					block: 'end'
				});
				else window.scrollTo(0, document.body.scrollHeight);
			}

			const {
				AUTOSCROLL_MIN_DELAY_MS: min,
				AUTOSCROLL_MAX_DELAY_MS: max
			} = CONFIG.TIMING;
			this._scheduleNext(min + Math.floor(Math.random() * (max - min)));
		}
	}

	// Re-orders video <li> items by duration, award status, or view count.
	class VideoSorter {
		constructor(stateManager) {
			this._state = stateManager;
		}

		findVideoLists(includePlaylist = null) {
			const allLists = Utils.safeQuerySelectorAll(CONFIG.SELECTORS.VIDEO_LISTS);
			if (includePlaylist === null) {
				includePlaylist = this._state.get('sortWithinPlaylistsState');
			}
			return allLists.filter(list => {
				const isInPlaylist = CONFIG.SELECTORS.PLAYLIST_CONTAINERS.some(
					sel => list.closest(sel) || list.matches(sel) || list.id.toLowerCase().includes('playlist')
				);
				if (!includePlaylist && isInPlaylist) {
					Utils.log(`VideoSorter: excluding playlist container "${list.id || list.className}"`);
					return false;
				}
				return true;
			});
		}

		findPlaylistLists() {
			return CONFIG.SELECTORS.PLAYLIST_CONTAINERS
				.flatMap(sel => Utils.safeQuerySelectorAll(`${sel} ul.videos`));
		}

		sortByDuration(forceIncludePlaylist = false) {
			const lists = forceIncludePlaylist ? [...new Set([...this.findPlaylistLists(), ...this.findVideoLists(true)])] :
				this.findVideoLists();
			Utils.log(`VideoSorter: sorting ${lists.length} list(s) by duration`);
			lists.forEach(list => this._sortListByDuration(list));
		}

		_sortListByDuration(list) {
			const items = Utils.safeQuerySelectorAll('li', list).filter(li => li.querySelector('.duration'));
			if (items.length === 0) return;
			try {
				items.sort((a, b) => {
					const da = Utils.parseDuration(a.querySelector('.duration')?.textContent ?? '0');
					const db = Utils.parseDuration(b.querySelector('.duration')?.textContent ?? '0');
					return db - da;
				});
				items.forEach(item => list.appendChild(item));
			} catch (err) {
				handleError('VideoSorter._sortListByDuration', err);
			}
		}

		sortByAward(forceIncludePlaylist = false) {
			const lists = forceIncludePlaylist ? [...new Set([...this.findPlaylistLists(), ...this.findVideoLists(true)])] :
				this.findVideoLists();
			Utils.log(`VideoSorter: sorting ${lists.length} list(s) by award`);
			lists.forEach(list => this._sortListByAward(list));
		}

		_sortListByAward(list) {
			const items = Utils.safeQuerySelectorAll('li', list);
			const awarded = items.filter(i => i.querySelector('[class*="award-icon"]'));
			const others = items.filter(i => !i.querySelector('[class*="award-icon"]'));
			Utils.log(`VideoSorter: ${awarded.length} award / ${others.length} other in "${list.id || list.className}"`);
			[...awarded, ...others].forEach(item => list.appendChild(item));
		}

		// Sorts all applicable lists by view count (most viewed first).
		sortByViews(forceIncludePlaylist = false) {
			const lists = forceIncludePlaylist ?
				[...new Set([...this.findPlaylistLists(), ...this.findVideoLists(true)])] :
				this.findVideoLists();
			Utils.log(`VideoSorter: sorting ${lists.length} list(s) by views`);
			lists.forEach(list => this._sortListByViews(list));
		}

		// Parses a view count string like "62.8K", "1.2M", or "945" into a plain number.
		// Handles K (thousands), M (millions), B (billions) suffixes.
		_parseViews(viewString) {
			if (!viewString || typeof viewString !== 'string') return 0;
			const s = viewString.trim().replace(/,/g, '');
			const num = parseFloat(s);
			if (isNaN(num)) return 0;
			if (s.endsWith('K') || s.endsWith('k')) return num * 1_000;
			if (s.endsWith('M') || s.endsWith('m')) return num * 1_000_000;
			if (s.endsWith('B') || s.endsWith('b')) return num * 1_000_000_000;
			return num;
		}

		// Reads the .views var text from each <li> and re-appends in descending order.
		// Items without a views element are treated as 0 and sorted to the bottom.
		_sortListByViews(list) {
			const items = Utils.safeQuerySelectorAll('li', list);
			if (items.length === 0) return;
			try {
				items.sort((a, b) => {
					const va = this._parseViews(a.querySelector('.views var')?.textContent ?? '0');
					const vb = this._parseViews(b.querySelector('.views var')?.textContent ?? '0');
					return vb - va; // descending: most viewed first
				});
				items.forEach(item => list.appendChild(item));
			} catch (err) {
				handleError('VideoSorter._sortListByViews', err);
			}
		}
	}

	// Duration bounds in whole minutes. Drives both the listing-URL rewrite
	// (min_duration/max_duration) and the cosmetic hide of out-of-range items.
	class DurationFilter {
		static MIN_KEY = 'savedMinDuration';
		static MAX_KEY = 'savedMaxDuration';

		constructor(stateManager) {
			this._state = stateManager;
		}

		// Parses a stored/typed value into whole minutes. Returns null for
		// anything empty or invalid so callers can treat the bound as unset.
		static parseMinutes(raw) {
			if (raw === null || raw === undefined) return null;
			const trimmed = String(raw).trim();
			if (!trimmed) return null;
			const minutes = parseInt(trimmed, 10);
			return (Number.isFinite(minutes) && minutes >= 0) ? minutes : null;
		}

		// Returns the currently saved bounds as {min, max}; each is either a
		// non-negative integer (minutes) or null when unset/invalid.
		static getBounds() {
			return {
				min: DurationFilter.parseMinutes(CrossDomainStorage.getItem(DurationFilter.MIN_KEY)),
				max: DurationFilter.parseMinutes(CrossDomainStorage.getItem(DurationFilter.MAX_KEY)),
			};
		}

		static setMin(raw) {
			CrossDomainStorage.setItem(DurationFilter.MIN_KEY, raw ?? '');
		}

		static setMax(raw) {
			CrossDomainStorage.setItem(DurationFilter.MAX_KEY, raw ?? '');
		}

		// Listing pages that accept min_duration / max_duration. Individual videos
		// live under /view_video.php, so this cannot match a watch page.
		static _isListingPage() {
			return /^\/video(\/|$)/i.test(window.location.pathname);
		}

		static isOutOfRange(seconds) {
			if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return false;
			const { min, max } = DurationFilter.getBounds();
			if (min === null && max === null) return false;
			if (min !== null && seconds < min * 60) return true;
			if (max !== null && seconds > max * 60) return true;
			return false;
		}

		enforceListingUrl() {
			try {
				if (!this._state.get('enforceSearchDurationState')) return;
				if (!DurationFilter._isListingPage()) return;

				const { min, max } = DurationFilter.getBounds();
				if (min === null && max === null) return;

				const url = new URL(window.location.href);
				const params = url.searchParams;
				const desiredMin = min !== null ? String(min) : null;
				const desiredMax = max !== null ? String(max) : null;

				if (params.get('min_duration') === desiredMin && params.get('max_duration') === desiredMax) {
					return;
				}

				if (desiredMin !== null) params.set('min_duration', desiredMin);
				else params.delete('min_duration');
				if (desiredMax !== null) params.set('max_duration', desiredMax);
				else params.delete('max_duration');

				Utils.log(`DurationFilter: applying listing duration range (min=${desiredMin ?? '-'}min, max=${desiredMax ?? '-'}min)`);
				window.location.replace(url.toString());
			} catch (err) {
				handleError('DurationFilter.enforceListingUrl', err);
			}
		}
	}

	// Hides individual video <li> items matching any active filter.
	class VideoHider {
		constructor(stateManager, videoSorter) {
			this._state = stateManager;
			this._videoSorter = videoSorter;
			this._cachedFilterWords = null;
			this._lastFilterString = null;
		}

		getFilterWords() {
			const current = CrossDomainStorage.getItem('savedFilterWords') ?? '';
			if (current !== this._lastFilterString) {
				this._lastFilterString = current;
				this._cachedFilterWords = Utils.sanitizeFilterWords(current);
			}
			return this._cachedFilterWords;
		}

		// Returns the show-only word list (videos NOT matching any of these are hidden).
		getShowWords() {
			return Utils.sanitizeFilterWords(CrossDomainStorage.getItem('savedShowWords') ?? '');
		}

		hideVideos(addedNodes = null) {
			const hideWatched = this._state.get('hideWatchedState');
			const hidePaid = this._state.get('hidePaidContentState');
			const hideVR = this._state.get('hideVRState');
			const hideShorts = this._state.get('hideShortsState');
			const hideDurationOutOfRange = this._state.get('hideDurationOutOfRangeState');
			const filterWords = this.getFilterWords();
			const showWords = this.getShowWords();

			const shortsSection = Utils.safeQuerySelector(CONFIG.SELECTORS.SHORTS_SECTION);
			if (shortsSection) {
				shortsSection.style.display = hideShorts ? 'none' : '';
			}

			let items;

			if (addedNodes && addedNodes.length > 0) {
				items = addedNodes.flatMap(node => {
					if (node.nodeType !== Node.ELEMENT_NODE) return [];
					if (node.tagName === 'LI') return [node];
					return Array.from(node.querySelectorAll('li'));
				});
				Utils.log(`VideoHider: incremental pass, ${items.length} new item(s)`);
			} else {
				const lists = this._videoSorter.findVideoLists(true);
				items = lists.flatMap(list => Utils.safeQuerySelectorAll('li', list));
				Utils.log(`VideoHider: full pass, ${items.length} item(s)`);
			}

			for (const item of items) {
				try {
					item.style.display = this._shouldHide(item, {
							hideWatched,
							hidePaid,
							hideVR,
							hideDurationOutOfRange,
							filterWords,
							showWords
						}) ?
						'none' :
						'';
				} catch (err) {
					handleError('VideoHider.hideVideos (item)', err, 'warn');
				}
			}
		}

		_shouldHide(item, {
			hideWatched,
			hidePaid,
			hideVR,
			hideDurationOutOfRange,
			filterWords,
			showWords
		}) {
			if (hideWatched) {
				const watched = item.querySelector(CONFIG.SELECTORS.WATCHED_INDICATORS);
				if (watched && !watched.classList.contains('hidden')) return true;
			}
			if (hidePaid) {
				const isPaid =
					item.querySelector(CONFIG.SELECTORS.PAID_CONTENT) ||
					item.querySelector('a')?.getAttribute('href') === 'javascript:void(0)';
				if (isPaid) return true;
			}
			if (hideVR && item.querySelector(CONFIG.SELECTORS.VR_INDICATOR)) {
				return true;
			}
			if (hideDurationOutOfRange) {
				const durationText = item.querySelector('.duration')?.textContent;
				if (durationText) {
					const seconds = Utils.parseDuration(durationText);
					if (DurationFilter.isOutOfRange(seconds)) return true;
				}
			}
			const text = item.textContent.toLowerCase();
			if (filterWords.length > 0) {
				if (filterWords.some(w => text.includes(w))) return true;
			}
			// Show-only: hide anything that doesn't match at least one show word
			if (showWords.length > 0) {
				if (!showWords.some(w => text.includes(w))) return true;
			}
			return false;
		}
	}

	// Player helpers: auto-mute, volume syncing, and cursor hiding.
	const VideoPlayer = {
		// Latches true after a successful mute so we don't re-fire mute events on
		// every pass; reset by resetMuteState() when the tab is hidden.
		_hasMuted: false,

		mute(force = false) {
			if (VideoPlayer._hasMuted && !force) return;

			const videos = Utils.safeQuerySelectorAll(CONFIG.SELECTORS.VIDEO_ELEMENT);
			for (const video of videos) {
				video.muted = true;
			}

			if (videos.length > 0) {
				Utils.log(`VideoPlayer: muted ${videos.length} player(s)`);
				VideoPlayer._hasMuted = true;
			}
		},

		// Clears the mute latch so the next mute() call will fire again.
		resetMuteState() {
			VideoPlayer._hasMuted = false;
		},

		// Elements already wired up, so re-running applySavedVolume() on every
		// visibility restore doesn't stack duplicate listeners.
		_watchedVolumeElements: new WeakSet(),

		// Last real user interaction, used to tell a deliberate volume change apart
		// from one the browser made on its own.
		_lastGesture: 0,
		_gestureTrackingReady: false,

		// Mobile browsers set video.muted themselves for autoplay policy, and the
		// player mutes programmatically during setup. Both fire 'volumechange' just
		// like a real action, and persisting them clobbered savedMuted everywhere.
		_initGestureTracking() {
			if (VideoPlayer._gestureTrackingReady) return;
			VideoPlayer._gestureTrackingReady = true;
			const mark = () => {
				VideoPlayer._lastGesture = Date.now();
			};
			for (const type of ['pointerdown', 'touchstart', 'keydown']) {
				document.addEventListener(type, mark, {
					capture: true,
					passive: true
				});
			}
		},

		// True on iOS and iPadOS, where video.volume is read-only and always reports
		// 1. Saving that would push volume=1 over whatever was set on desktop.
		_hasReadOnlyVolume() {
			try {
				return /iP(hone|ad|od)/.test(navigator.platform || '') ||
					(navigator.userAgent.includes('Mac') && 'ontouchend' in document);
			} catch (_) {
				return false;
			}
		},

		_readVolumeFromPlayerStorage() {
			try {
				const existing = localStorage.getItem('mgp_player');
				if (!existing) return null;

				const parsed = JSON.parse(existing);
				if (!parsed || typeof parsed !== 'object' || !parsed.volume || typeof parsed.volume.volume !== 'number') {
					return null;
				}

				return String(Math.max(0, Math.min(100, parsed.volume.volume)) / 100);
			} catch (err) {
				return null;
			}
		},

		_readMutedFromPlayerStorage() {
			try {
				const existing = localStorage.getItem('mgp_player');
				if (!existing) return null;

				const parsed = JSON.parse(existing);
				if (!parsed || typeof parsed !== 'object' || !parsed.volume || typeof parsed.volume.muted !== 'boolean') {
					return null;
				}

				return String(parsed.volume.muted);
			} catch (err) {
				return null;
			}
		},

		applySavedVolume() {
			try {
				const videos = Utils.safeQuerySelectorAll(CONFIG.SELECTORS.VIDEO_ELEMENT);
				if (videos.length === 0) {
					Utils.log('VideoPlayer: no .mgp_videoElement on this page, nothing to apply volume to');
					return;
				}

				// Watch before checking for a saved value: doing it only in the branch below
				// meant the very first volume change was never captured.
				for (const video of videos) {
					VideoPlayer._watchVolume(video);
				}

				let volumeRaw = CrossDomainStorage.getItem('savedVolume');
				if (volumeRaw === null) {
					volumeRaw = VideoPlayer._readVolumeFromPlayerStorage();
					if (volumeRaw !== null) {
						CrossDomainStorage.setItem('savedVolume', volumeRaw);
						Utils.log(`VideoPlayer: bootstrapped saved volume from mgp_player (${volumeRaw})`);
					}
				}
				if (volumeRaw !== null) {
					const volume = parseFloat(volumeRaw);
					if (Number.isFinite(volume) && volume >= 0 && volume <= 1) {
						for (const video of videos) {
							video.volume = volume;
						}
					}
				} else {
					Utils.log('VideoPlayer: no saved volume yet, nothing to apply');
				}

				let mutedRaw = CrossDomainStorage.getItem('savedMuted');
				if (mutedRaw === null) {
					mutedRaw = VideoPlayer._readMutedFromPlayerStorage();
					if (mutedRaw !== null) {
						CrossDomainStorage.setItem('savedMuted', mutedRaw);
						Utils.log(`VideoPlayer: bootstrapped saved muted from mgp_player (${mutedRaw})`);
					}
				}
				if (mutedRaw !== null) {
					const muted = mutedRaw === 'true';
					for (const video of videos) {
						video.muted = muted;
					}
				} else {
					Utils.log('VideoPlayer: no saved muted state yet, nothing to apply');
				}
			} catch (err) {
				handleError('VideoPlayer.applySavedVolume', err, 'warn');
			}
		},

		_watchVolume(video) {
			if (VideoPlayer._watchedVolumeElements.has(video)) return;
			VideoPlayer._watchedVolumeElements.add(video);
			VideoPlayer._initGestureTracking();

			const saveVolume = Utils.debounce(() => {
				// Only persist a change that plausibly came from the user. See
				// _initGestureTracking() for why this matters on mobile.
				if (Date.now() - VideoPlayer._lastGesture > CONFIG.TIMING.VOLUME_GESTURE_WINDOW_MS) {
					Utils.log('VideoPlayer: volumechange with no recent user gesture, not saving');
					return;
				}
				try {
					CrossDomainStorage.setItem('savedMuted', String(video.muted));
					if (!VideoPlayer._hasReadOnlyVolume()) {
						CrossDomainStorage.setItem('savedVolume', String(video.volume));
					}
				} catch (err) {
					handleError('VideoPlayer.volumechange', err, 'warn');
				}
			}, 300);
			video.addEventListener('volumechange', saveVolume);
		},

		toggleCursorHide(enabled) {
			const STYLE_ID = 'phpro-cursor-hide-style';
			const existing = document.getElementById(STYLE_ID);

			if (enabled && !existing) {
				const style = Utils.createElement('style', {
					id: STYLE_ID,
					textContent: `
                        @keyframes hideCursor {
                          0%, 99% { cursor: default; }
                          100%     { cursor: none; }
                        }
                        .mgp_playingState { animation: none; }
                        .mgp_playingState:hover {
                          animation: hideCursor ${CONFIG.TIMING.CURSOR_HIDE_DELAY_S}s forwards;
                        }
                    `,
				});
				document.head.appendChild(style);
				Utils.log('VideoPlayer: cursor-hide style added');
			} else if (!enabled && existing) {
				existing.remove();
				Utils.log('VideoPlayer: cursor-hide style removed');
			}
		},
	};

	// Injects a download button into the player toolbar, sourcing MP4 URLs from
	// window.mediaDefinitions.
	class DownloadManager {
		static _buttonAdded = false;

		// Accepts the stateManager and eventEmitter as explicit dependencies
		// instead of reaching into stateManager._eventEmitter.
		static init(stateManager, eventEmitter) {
			// React to toggle changes from the menu
			eventEmitter.on('stateChanged', ({
				key,
				newValue
			}) => {
				if (key === 'downloadButtonState') {
					if (newValue === true) {
						setTimeout(() => DownloadManager.addButton(), 1500);
					} else {
						DownloadManager.removeButton();
					}
				}
			});

			// Initial load if already enabled
			if (stateManager.get('downloadButtonState', false)) {
				setTimeout(() => DownloadManager.addButton(), CONFIG.TIMING.DOWNLOAD_BUTTON_DELAY_MS);
			}
		}

		static removeButton() {
			document.querySelectorAll('.mgp_button[data-phpro-download]').forEach(b => b.remove());
			DownloadManager._buttonAdded = false;
		}

		static addButton() {
			if (DownloadManager._buttonAdded) return;
			DownloadManager._buttonAdded = true;

			const script = document.createElement('script');
			script.textContent = `
            (function() {
                'use strict';

                function log(msg) {
                    console.log('%c[PH-PRO Download] ' + msg, 'color:#ff9800;font-weight:bold');
                }

                // Strip filesystem-illegal characters and limit length so the
                // download doesn't fail silently on Windows or hit FS limits.
                function sanitizeFilename(name) {
                    return (name || 'video')
                        .replace(/[<>:"/\\\\|?*\\x00-\\x1f]/g, '_')
                        .replace(/\\s+/g, ' ')
                        .trim()
                        .slice(0, 200) || 'video';
                }

                async function findVideoUrl() {
                    log("Searching for mediaDefinitions...");

                    // This is the working method you had
                    const mediaObj = Object.values(window).find(v => v?.mediaDefinitions);
                    let media = mediaObj ? mediaObj.mediaDefinitions : null;

                    if (!media) {
                        log("Fallback: looking for window.mediaDefinitions directly");
                        media = window.mediaDefinitions;
                    }

                    if (!Array.isArray(media) || media.length === 0) {
                        throw new Error("mediaDefinitions not found or empty");
                    }

                    log("Found " + media.length + " media entries");

                    // Try direct mp4 first (non-remote)
                    let videoUrl = media.find(v => v.format === "mp4" && !v.remote)?.videoUrl;

                    if (!videoUrl) {
                        // Remote quality list fallback (very common)
                        const remote = media.find(v => v.remote && v.videoUrl);
                        if (remote) {
                            log("Fetching remote quality list...");
                            const res = await fetch(remote.videoUrl);
                            const list = await res.json();
                            if (Array.isArray(list) && list.length > 0) {
                                list.sort((a, b) => (b.quality || 0) - (a.quality || 0));
                                videoUrl = list[0].videoUrl;
                                log("Using highest quality: " + (list[0].quality || "unknown") + "p");
                            }
                        }
                    }

                    if (!videoUrl) throw new Error("No valid video URL found");
                    return videoUrl;
                }

                function showToast(text) {
                    let toast = document.getElementById('phpro-download-toast');
                    if (!toast) {
                        toast = document.createElement('div');
                        toast.id = 'phpro-download-toast';
                        toast.style.cssText = 'position:fixed;bottom:25px;left:25px;padding:12px 18px;background:rgba(0,0,0,0.92);color:#fff;font-size:14px;border-radius:8px;z-index:2147483647;border:1px solid #ff9800;transition:opacity .3s;';
                        document.body.appendChild(toast);
                    }
                    toast.textContent = text;
                    toast.style.opacity = '1';
                    return toast;
                }

                function hideToast() {
                    const toast = document.getElementById('phpro-download-toast');
                    if (toast) toast.style.opacity = '0';
                    setTimeout(() => toast?.remove(), 400);
                }

                async function startDownload() {
                    const toast = showToast("Finding highest quality stream...");
                    try {
                        const videoUrl = await findVideoUrl();
                        toast.textContent = "Downloading...";

                        const res = await fetch(videoUrl);
                        const reader = res.body.getReader();
                        const total = +res.headers.get('Content-Length') || 0;
                        let received = 0;
                        const chunks = [];

                        while (true) {
                            const { done, value } = await reader.read();
                            if (done) break;
                            chunks.push(value);
                            received += value.length;

                            if (total) {
                                const percent = ((received / total) * 100).toFixed(1);
                                toast.textContent = \`Downloading... \${percent}%\`;
                            } else {
                                toast.textContent = \`Downloading... \${(received / 1024 / 1024).toFixed(1)} MB\`;
                            }
                        }

                        const blob = new Blob(chunks);
                        const url = URL.createObjectURL(blob);
                        const a = document.createElement('a');
                        a.href = url;
                        const rawTitle = document.title.replace(/- Pornhub\\.com.*/i, '').trim();
                        a.download = sanitizeFilename(rawTitle) + ".mp4";
                        a.click();
                        URL.revokeObjectURL(url);

                        toast.textContent = "Download started";
                        setTimeout(hideToast, 1800);
                    } catch (err) {
                        console.error(err);
                        toast.textContent = "Download failed - check console";
                        setTimeout(hideToast, 3000);
                    }
                }

                // Inject the button
                function injectButton() {
                    document.querySelectorAll('.mgp_button[data-phpro-download]').forEach(b => b.remove());

                    const fullscreenBtn = document.querySelector('.mgp_fullscreen');
                    if (!fullscreenBtn) return false;

                    const btn = document.createElement('div');
                    btn.className = 'mgp_button';
                    btn.dataset.phproDownload = 'true';
                    btn.style.pointerEvents = 'auto';
                    btn.innerHTML = \`
                      <div class="mgp_icon">
                          <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
                              <path d="M12 5V19M12 19L5 12M12 19L19 12"
                                    stroke="#ffffff"
                                    stroke-width="3"
                                    stroke-linecap="round"
                                    stroke-linejoin="round"/>
                          </svg>
                      </div>
                    \`;

                    btn.onclick = (e) => {
                        e.preventDefault();
                        e.stopImmediatePropagation();
                        startDownload();
                    };

                    // Tooltip
                    btn.addEventListener('mouseenter', () => {
                        const original = fullscreenBtn.getAttribute('data-text');
                        fullscreenBtn.setAttribute('data-text', "Download this video");
                        fullscreenBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
                        setTimeout(() => fullscreenBtn.setAttribute('data-text', original), 60);
                    });
                    btn.addEventListener('mouseleave', () => {
                        fullscreenBtn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
                    });

                    fullscreenBtn.parentNode.insertBefore(btn, fullscreenBtn);
                    log("Download button successfully injected");
                    return true;
                }

                // Try injecting with retries
                let attempts = 0;
                const interval = setInterval(() => {
                    attempts++;
                    if (injectButton() || attempts >= 10) {
                        clearInterval(interval);
                    }
                }, 800);
            })();
        `;

			document.documentElement.appendChild(script);
			script.remove();
		}
	}

	// Forces the site to load in English when the toggle is on.
	class LanguageManager {
		constructor(stateManager) {
			this._state = stateManager;
		}

		redirectToEnglish() {
			if (!this._state.get('redirectToEnglishState')) return;

			setTimeout(() => {
				try {
					const langCookie = this._getCookie('lang');
					if (langCookie !== 'en') {
						const hostParts = window.location.hostname.split('.');
						const baseDomain = hostParts.slice(-2).join('.');

						this._deleteCookie('lang');
						this._deleteCookie('lang', baseDomain);
						this._setCookie('lang', 'en', 365, baseDomain);

						Utils.log(`LanguageManager: set lang cookie to English for ${baseDomain}, redirecting`);

						const newUrl = `${window.location.protocol}//${baseDomain}${window.location.pathname}${window.location.search}`;
						window.location.href = newUrl;
						return;
					}

					const dropdown = Utils.safeQuerySelector(CONFIG.SELECTORS.LANGUAGE_DROPDOWN);
					const currentLang = dropdown?.querySelector('span.networkTab')?.textContent.trim().toLowerCase();
					if (currentLang !== 'en') {
						const englishLink = Utils.safeQuerySelector(CONFIG.SELECTORS.ENGLISH_OPTION);
						if (englishLink) {
							englishLink.click();
							Utils.log('LanguageManager: redirected to English');
						}
					}
				} catch (err) {
					handleError('LanguageManager.redirectToEnglish', err);
				}
			}, CONFIG.TIMING.LANGUAGE_CHECK_DELAY_MS);
		}

		_getCookie(name) {
			const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
			return match ? decodeURIComponent(match[2]) : null;
		}

		_setCookie(name, value, days, domain) {
			const expires = new Date();
			expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
			const domainPart = domain ? `;domain=${domain}` : '';
			document.cookie = `${name}=${encodeURIComponent(value)};expires=${expires.toUTCString()};path=/${domainPart}`;
		}

		_deleteCookie(name, domain) {
			const domainPart = domain ? `;domain=${domain}` : '';
			document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/${domainPart}`;
		}
	}

	// Hides persistent site clutter: country-redirect banners, A/B containers,
	// welcome modals, GDPR notices. Runs on DOMContentLoaded and again on load,
	// since some of them are injected after the initial parse.
	const ElementHider = {
		hideElements() {
			Utils.log('ElementHider: hiding unwanted elements');
			for (const selector of CONFIG.SELECTORS.ELEMENTS_TO_HIDE) {
				try {
					Utils.safeQuerySelectorAll(selector).forEach(el => {
						el.style.display = 'none';
					});
				} catch (err) {
					handleError(`ElementHider.hideElements("${selector}")`, err, 'warn');
				}
			}
		},
	};

	// Overlays a playlist item in red as soon as its delete button is clicked.
	class PlaylistManager {
		init() {
			document.addEventListener('click', event => {
				const deleteButton = event.target?.closest('button[onclick="deleteFromPlaylist(this);"]');
				if (deleteButton) {
					this._addRedOverlay(deleteButton);
				}
			});
		}

		_addRedOverlay(element) {
			try {
				const parentLi = element.closest('li');
				if (!parentLi) return;

				if (parentLi.querySelector('.phpro-delete-overlay')) return;

				const overlay = Utils.createElement('div', {
					className: 'phpro-delete-overlay',
					style: {
						position: 'absolute',
						top: '0',
						left: '0',
						width: '100%',
						height: '100%',
						backgroundColor: 'red',
						opacity: '0.5',
						pointerEvents: 'none',
						zIndex: '1000',
					},
				});
				parentLi.style.position = 'relative';
				parentLi.appendChild(overlay);
			} catch (err) {
				handleError('PlaylistManager._addRedOverlay', err, 'warn');
			}
		}
	}

	// Smooth scroll helper, kept separate so MenuManager has no direct window
	// dependency.
	class ScrollToTop {
		scrollToTop() {
			window.scrollTo({
				top: 0,
				behavior: 'smooth'
			});
		}
	}

	// Builds and manages the floating menu button and the panel it opens.
	class MenuManager {
		constructor(stateManager, eventEmitter, autoScroller, scrollToTop) {
			this._state = stateManager;
			this._eventEmitter = eventEmitter;
			this._autoScroller = autoScroller;
			this._scrollToTop = scrollToTop;

			this._menu = null;
			this._toggleButton = null;
			this._styleSheet = null;
			this._sections = []; // tracks all collapsible sections for state sync
			// Per-type references to the keyword-filter input + chip container.
			// Populated by _buildFilterBlock(); keyed by filter type ('hide'/'show').
			this._filterRefs = {};
			// Tracks which screen edge last opened the menu via swipe, so the close
			// gesture knows which direction to accept. Null when opened via button.
			this._swipeOpenedFromSide = null;
			this._fadeTimer = null; // timeout ID for the menu-button fade-out (swipe-to-open mode)
			this._quickMenuPanel = null; // the floating quick-menu DOM element, if currently shown

			this._features = this._buildFeatureDefinitions();

			this._eventEmitter.on('autoscrollStateChanged', this._onAutoscrollStateChanged.bind(this));
			// Keeps every visible copy of a toggle in sync. Without this, flipping a
			// toggle in the quick menu left the main menu's switch stale.
			this._eventEmitter.on('stateChanged', ({ key }) => {
				this._syncFeatureToggleVisual(key);
				this._renderQuickMenu();
			});
		}

		_syncFeatureToggleVisual(key) {
			const feature = this._features.find(f => f.key === key);
			if (!feature) return;
			const isActive = this._state.get(feature.key, feature.defaultState);
			for (const id of [feature.id, `phpro-qm-${feature.id}`]) {
				const track = document.getElementById(id);
				if (!track) continue;
				const thumb = track.querySelector('div');
				track.style.backgroundColor = isActive ? 'orange' : '#666';
				track.setAttribute('aria-checked', String(isActive));
				if (thumb) thumb.style.left = isActive ? '22px' : '2px';
			}
		}

		// Public entry point. Builds all UI elements and appends them to <html>
		// (not <body>, so they survive any body replacements by the site).
		create() {
			Utils.log('MenuManager: creating menu');
			try {
				this._styleSheet = this._addMenuStyles();
				this._menu = this._createMenuContainer();
				this._applyOpacityToMenuAndButton();
				this._applyMenuSide();
				this._menu.style.top = '40px';
				this._addSection('General', 'general');
				this._addSection('Player', 'player');
				this._addSection('Sorting', 'sorting',
					{ position: 'before', fn: c => c.appendChild(this._createSortDropdown()) },
					{ position: 'after', fn: c => this._appendManualSortButtons(c) }
				);
				const filteringContent = this._addSection('Filtering', null);
				this._addSubSection(filteringContent, 'Filtering', 'By type', 'byType');
				this._addSubSection(filteringContent, 'Filtering', 'By duration', 'duration',
					{ position: 'after', fn: c => this._populateDurationFilter(c) }
				);
				this._addSubSection(filteringContent, 'Filtering', 'By keywords', null,
					{ position: 'after', fn: c => this._populateFilterWords(c) }
				);
				this._addSection('Autoscroll', null,
					{ position: 'after', fn: c => this._appendScrollButtons(c) }
				);
				this._addCloseFooterButton();
				document.documentElement.appendChild(this._menu);
				this._toggleButton = this._createToggleButton();
				document.documentElement.appendChild(this._toggleButton);
				this._scheduleButtonFade();
				this._setupPanelDismiss();
				this._setupTouchHandlers();
				// Shows the floating quick-menu panel immediately if it was left
				// enabled with at least one item selected from a previous visit.
				this._renderQuickMenu();
			} catch (err) {
				handleError('MenuManager.create', err);
			}
		}

		_addMenuStyles() {
			return Utils.addStylesheet(`
                .phpro-category-header {
                    color: orange;
                    background-color: #1e1e1e;
                    margin: 20px 0 10px;
                    display: block;
                    font-size: 16px;
                    padding: 10px;
                    border-radius: 4px;
                    text-transform: uppercase;
                    border-left: 3px solid transparent;
                }
                .phpro-category-header:first-of-type {
                    margin-top: 0;
                }
                .phpro-section-header {
                    cursor: pointer;
                    user-select: none;
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                }
                .phpro-section-header:hover {
                    background-color: #2a2a2a;
                }
                /* When transparency is on, the menu gets .phpro-transparent. The
                   section headers get a semi-transparent tint plus a solid orange
                   left border so they're always distinguishable regardless of
                   what colour is behind the menu - a pure black background would
                   make the tint alone invisible. */
                #sideMenu.phpro-transparent .phpro-category-header {
                    background-color: rgba(0,0,0,0.65);
                    border-left-color: orange;
                }
                #sideMenu.phpro-transparent .phpro-section-header:hover {
                    background-color: rgba(0,0,0,0.72);
                }
                /* Orange scrollbar for the menu panel. */
                #sideMenu::-webkit-scrollbar {
                    width: 6px;
                }
                #sideMenu::-webkit-scrollbar-track {
                    background: transparent;
                }
                #sideMenu::-webkit-scrollbar-thumb {
                    background-color: orange;
                    border-radius: 3px;
                }
                #sideMenu {
                    scrollbar-color: orange transparent;
                    scrollbar-width: thin;
                }
                .phpro-section-arrow {
                    font-style: normal;
                    font-size: 13px;
                    transition: transform 0.2s;
                    margin-left: 8px;
                    flex-shrink: 0;
                }
                .phpro-quickmenu-gear-wrap {
                    position: relative;
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                    padding: 6px;
                    margin: -6px -6px -6px 0;
                    cursor: pointer;
                    border-radius: 6px;
                    flex-shrink: 0;
                    transition: background-color 0.2s;
                }
                .phpro-quickmenu-gear-wrap:hover {
                    background-color: rgba(255,165,0,0.15);
                }
                .phpro-quickmenu-gear-wrap:focus-visible {
                    outline: 2px solid orange;
                    outline-offset: 2px;
                }
                .phpro-quickmenu-gear {
                    display: inline-flex;
                    transition: transform 0.3s ease;
                }
                .phpro-quickmenu-gear--open {
                    transform: rotate(45deg);
                }
                .phpro-quickmenu-tooltip {
                    position: absolute;
                    bottom: 100%;
                    right: 0;
                    margin-bottom: 4px;
                    padding: 4px 8px;
                    background-color: rgba(0,0,0,0.92);
                    color: white;
                    font-size: 11px;
                    white-space: nowrap;
                    border-radius: 4px;
                    border: 1px solid orange;
                    opacity: 0;
                    visibility: hidden;
                    pointer-events: none;
                    z-index: 10;
                }
                .phpro-quickmenu-gear-wrap:hover .phpro-quickmenu-tooltip,
                .phpro-quickmenu-gear-wrap:focus-visible .phpro-quickmenu-tooltip {
                    opacity: 1;
                    visibility: visible;
                }
                .phpro-section-content {
                    overflow: hidden;
                }
                .phpro-subsection-header {
                    color: #ddd;
                    background-color: transparent;
                    margin: 12px 0 6px;
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    font-size: 13px;
                    padding: 6px 8px;
                    border-radius: 4px;
                    text-transform: none;
                    cursor: pointer;
                    user-select: none;
                    border-left: 2px solid #555;
                }
                .phpro-subsection-header:hover {
                    background-color: #2a2a2a;
                }
                .phpro-subsection-content {
                    overflow: hidden;
                    padding-left: 10px;
                    border-left: 2px solid #444;
                    margin-left: 2px;
                }
                .phpro-quick-menu {
                    position: fixed;
                    bottom: 16px;
                    right: 16px;
                    width: 220px;
                    box-sizing: border-box;
                    background-color: rgba(15,15,15,0.55);
                    backdrop-filter: blur(20px);
                    -webkit-backdrop-filter: blur(20px);
                    border: 1px solid rgba(255,255,255,0.18);
                    border-radius: 10px;
                    padding: 10px 12px;
                    z-index: 999999997;
                    max-height: 70vh;
                    overflow-y: auto;
                    font-family: Arial, sans-serif;
                    box-shadow: 0 4px 16px rgba(0,0,0,0.5);
                    opacity: 1;
                    transition: opacity 0.4s ease;
                }
                .phpro-quick-menu-faded {
                    opacity: 0.1;
                }
                .phpro-quick-menu-header {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    margin-bottom: 10px;
                    padding-bottom: 8px;
                    border-bottom: 1px solid rgba(255,255,255,0.15);
                    user-select: none;
                }
                .phpro-quick-menu-dismiss {
                    background: none;
                    border: none;
                    color: white;
                    font-size: 18px;
                    line-height: 1;
                    cursor: pointer;
                    padding: 0 4px;
                }
                .phpro-quick-menu-dismiss:hover {
                    color: orange;
                }
                .phpro-quick-menu-body {
                    display: flex;
                    flex-direction: column;
                    gap: 4px;
                }
                /* Buttons inside the quick menu get the same frosted-glass
                   treatment as the panel itself, overriding the solid
                   background _createActionButton()/_refreshButtonTints() set
                   inline (hence !important - inline styles otherwise win). */
                .phpro-quick-menu .phpro-tintable-btn {
                    background-color: rgba(255,255,255,0.08) !important;
                    backdrop-filter: blur(8px);
                    -webkit-backdrop-filter: blur(8px);
                    border: 1px solid rgba(255,255,255,0.25);
                }
                .phpro-quick-menu .phpro-tintable-btn:hover {
                    background-color: rgba(255,255,255,0.16) !important;
                }
                .phpro-quick-menu-open-settings {
                    display: block;
                    width: 100%;
                    padding: 8px 12px;
                    background-color: black;
                    color: white;
                    border: 1px solid white;
                    border-radius: 10px;
                    cursor: pointer;
                    font-size: 13px;
                    font-family: inherit;
                    transition: all 0.3s;
                }
                .phpro-quick-menu-open-settings:hover {
                    color: orange;
                    border-color: orange;
                }
                .phpro-checkbox {
                    appearance: none;
                    -webkit-appearance: none;
                    width: 18px;
                    height: 18px;
                    margin: 0;
                    flex-shrink: 0;
                    border: 2px solid #666;
                    border-radius: 6px;
                    background-color: #222;
                    cursor: pointer;
                    position: relative;
                    transition: background-color 0.2s, border-color 0.2s;
                }
                .phpro-checkbox:hover {
                    border-color: orange;
                }
                .phpro-checkbox:checked {
                    background-color: orange;
                    border-color: orange;
                }
                .phpro-checkbox:checked::after {
                    content: '';
                    position: absolute;
                    left: 5px;
                    top: 1px;
                    width: 4px;
                    height: 9px;
                    border: solid black;
                    border-width: 0 2px 2px 0;
                    transform: rotate(45deg);
                }
                .phpro-checkbox:focus-visible {
                    outline: 2px solid orange;
                    outline-offset: 2px;
                }
                .phpro-duration-slider-wrap {
                    width: 100%;
                }
                .phpro-duration-slider {
                    position: relative;
                    height: 16px;
                    margin: 0 9px;
                }
                .phpro-duration-slider-track {
                    position: absolute;
                    top: 50%;
                    left: 0;
                    right: 0;
                    height: 3px;
                    background-color: #555;
                    border-radius: 2px;
                    transform: translateY(-50%);
                }
                .phpro-duration-slider-range {
                    position: absolute;
                    top: 50%;
                    height: 3px;
                    background-color: orange;
                    border-radius: 2px;
                    transform: translateY(-50%);
                }
                .phpro-duration-slider-thumb {
                    position: absolute;
                    top: 50%;
                    width: 16px;
                    height: 16px;
                    border-radius: 50%;
                    background-color: white;
                    border: 2px solid orange;
                    transform: translate(-50%, -50%);
                    cursor: pointer;
                    touch-action: none;
                    z-index: 2;
                }
                .phpro-duration-slider-thumb:focus-visible {
                    outline: 2px solid orange;
                    outline-offset: 2px;
                }
                .phpro-duration-slider-labels {
                    display: flex;
                    justify-content: space-between;
                    margin: 8px 1px 0;
                    font-size: 11px;
                    color: #999;
                }
                .phpro-sort-row {
                    display: flex;
                    align-items: center;
                    margin-bottom: 10px;
                    width: 100%;
                    gap: 10px;
                }
                .phpro-sort-row label {
                    color: white;
                    font-size: 13px;
                    white-space: nowrap;
                    flex-shrink: 0;
                }
                .phpro-sort-select {
                    flex: 1;
                    background-color: #222;
                    color: white;
                    border: 1px solid #666;
                    border-radius: 6px;
                    padding: 4px 8px;
                    font-size: 13px;
                    cursor: pointer;
                    outline: none;
                    min-width: 0;
                }
                .phpro-sort-select:focus {
                    border-color: orange;
                }
                .phpro-sort-select option {
                    background-color: #222;
                    color: white;
                }
                .phpro-toggle-track:focus-visible {
                    outline: 2px solid orange;
                    outline-offset: 2px;
                }
                /* Marker class for the autoscroll-running visual state. Used by
                   _attachHoverEffects so it can distinguish the active state
                   without depending on the fragile inline backgroundColor value. */
                .phpro-autoscroll-running {
                    background-color: red !important;
                    border-color: red !important;
                }
            `);
		}

		_createMenuContainer() {
			return Utils.createElement('div', {
				id: 'sideMenu',
				role: 'dialog',
				'aria-label': 'Pornhub Pro-ish settings',
				style: {
					position: 'fixed',
					top: '5px',
					padding: '15px',
					maxHeight: '90vh',
					width: 'min-content',
					minWidth: '240px',
					backgroundColor: 'rgba(0,0,0,0.95)',
					zIndex: '999999999',
					display: 'none',
					borderRadius: '10px',
					border: '1px solid orange',
					boxSizing: 'border-box',
					overflowY: 'auto',
					fontFamily: 'Arial, sans-serif',
					fontSize: '13px',
					boxShadow: '0 8px 25px rgba(0,0,0,0.8)',
					transition: `transform ${CONFIG.TIMING.SLIDE_MS}ms cubic-bezier(0.25, 0.1, 0.25, 1)`,
				},
			});
		}

		_applyOpacityToMenuAndButton() {
			const isTransparent = this._state.get('opaqueMenuButtonState');

			if (this._toggleButton) {
				if (isTransparent) {
					this._toggleButton.style.backdropFilter = 'blur(1.5rem)';
					this._toggleButton.style.WebkitBackdropFilter = 'blur(1.5rem)';
					this._toggleButton.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
					this._toggleButton.style.opacity = '1';
				} else {
					this._toggleButton.style.backdropFilter = 'none';
					this._toggleButton.style.WebkitBackdropFilter = 'none';
					this._toggleButton.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
					this._toggleButton.style.opacity = '1';
				}
			}

			if (this._menu) {
				// Toggle the class that drives the section-header tint overrides in CSS.
				this._menu.classList.toggle('phpro-transparent', isTransparent);

				if (isTransparent) {
					// Apply backdrop blur to background behind the menu
					this._menu.style.backdropFilter = 'blur(1.5rem)';
					this._menu.style.WebkitBackdropFilter = 'blur(1.5rem)';
					this._menu.style.backgroundColor = 'rgba(0, 0, 0, 0.3)';
				} else {
					// Remove blur and use solid background
					this._menu.style.backdropFilter = 'none';
					this._menu.style.WebkitBackdropFilter = 'none';
					this._menu.style.backgroundColor = 'rgba(0, 0, 0, 1.0)';
				}
			}

			// Re-tint the action buttons so they match the transparency state.
			this._refreshButtonTints();

			Utils.log(`Menu: ${isTransparent ? 'transparent (background blurred)' : 'opaque'}`);
		}

		_restingButtonBg() {
			return this._state.get('opaqueMenuButtonState') ?
				CONFIG.BUTTON_BG.TRANSPARENT :
				CONFIG.BUTTON_BG.SOLID;
		}

		_refreshButtonTints() {
			if (!this._menu) return;
			const bg = this._restingButtonBg();
			for (const button of this._menu.querySelectorAll('.phpro-tintable-btn')) {
				if (button.classList.contains('phpro-autoscroll-running')) continue;
				button.style.backgroundColor = bg;
			}
		}

		_applySavedPosition(button, animate = false) {
			const MIN_DISTANCE = 12;

			try {
				const saved = CrossDomainStorage.getItem('phpro_menuButtonPos');
				const preferRight = this._state.get('menuButtonRightSideState', false);

				let left, top;

				if (saved) {
					const data = JSON.parse(saved);
					const vw = window.innerWidth;
					const vh = window.innerHeight;

					if (data.horizontal === 'left') {
						left = Math.max(MIN_DISTANCE, data.hDistance);
					} else {
						left = vw - button.offsetWidth - Math.max(MIN_DISTANCE, data.hDistance);
					}

					if (data.vertical === 'top') {
						top = Math.max(MIN_DISTANCE, data.vDistance);
					} else {
						top = vh - button.offsetHeight - Math.max(MIN_DISTANCE, data.vDistance);
					}

					if (preferRight && (!saved || Date.now() - (data.timestamp || 0) < 2000)) {
						left = vw - button.offsetWidth - MIN_DISTANCE;
					}
				} else {
					left = preferRight ?
						window.innerWidth - button.offsetWidth - MIN_DISTANCE :
						MIN_DISTANCE;
					top = MIN_DISTANCE;
				}

				left = Math.max(MIN_DISTANCE, Math.min(left, window.innerWidth - button.offsetWidth - MIN_DISTANCE));
				top = Math.max(MIN_DISTANCE, Math.min(top, window.innerHeight - button.offsetHeight - MIN_DISTANCE));

				if (animate) {
					button.style.transition = 'left 0.6s cubic-bezier(0.25, 0.1, 0.25, 1), top 0.6s cubic-bezier(0.25, 0.1, 0.25, 1), opacity 0.25s';
				} else {
					button.style.transition = 'opacity 0.25s, background-color 0.25s';
				}

				button.style.left = `${left}px`;
				button.style.top = `${top}px`;
				button.style.transform = 'translate(0px, 0px)';
			} catch (err) {
				handleError('MenuManager._applySavedPosition', err, 'warn');
				button.style.left = '12px';
				button.style.top = '12px';
			}
		}

		// Saves the button position as relative edge distances so it can be
		// restored correctly at any viewport size.
		_saveButtonPosition(button) {
			try {
				const rect = button.getBoundingClientRect();
				const vw = window.innerWidth;
				const vh = window.innerHeight;

				const distLeft = rect.left;
				const distRight = vw - rect.right;
				const distTop = rect.top;
				const distBottom = vh - rect.bottom;

				const positionData = {
					horizontal: distLeft < distRight ? 'left' : 'right',
					vertical: distTop < distBottom ? 'top' : 'bottom',
					hDistance: Math.min(distLeft, distRight),
					vDistance: Math.min(distTop, distBottom),
					timestamp: Date.now()
				};

				CrossDomainStorage.setItem('phpro_menuButtonPos', JSON.stringify(positionData));
				Utils.log(`Menu button position saved: ${positionData.vertical}-${positionData.horizontal}`);
			} catch (err) {
				handleError('MenuManager._saveButtonPosition', err, 'warn');
			}
		}

		// Returns the array of Feature objects defining every toggle in the menu.
		// Each feature specifies its label, storage key, change handler, and category.
		_buildFeatureDefinitions() {
			return [
				new Feature({
					label: 'Auto-confirm age',
					key: 'autoConfirmAgeState',
					handler: () => {
						if (this._state.get('autoConfirmAgeState')) {
							// Enabled mid-session: set the cookie now so the gate is
							// skipped from the next navigation onward.
							AgeGate.set();
						} else {
							AgeGate.clear();
						}
					},
					id: 'autoConfirmAgeToggle',
					defaultState: true,
					category: 'general',
				}),
				new Feature({
					label: 'Always use English',
					key: 'redirectToEnglishState',
					handler: () => this._eventEmitter.emit('redirectToEnglish'),
					id: 'redirectToEnglishToggle',
					defaultState: true,
					category: 'general',
				}),
				new Feature({
					label: 'Enable transparency',
					key: 'opaqueMenuButtonState',
					handler: () => {
						this._applyOpacityToMenuAndButton();
					},
					id: 'opaqueMenuButtonToggle',
					defaultState: false,
					category: 'general',
				}),
				new Feature({
					label: 'Menu on the right',
					key: 'menuOnRightState',
					handler: () => this._applyMenuSide(),
					id: 'menuOnRightToggle',
					defaultState: false,
					category: 'general',
				}),
				new Feature({
					label: 'Swipe to open',
					key: 'swipeToOpenState',
					handler: () => Utils.log(`Swipe to open: ${this._state.get('swipeToOpenState')}`),
					id: 'swipeToOpenToggle',
					defaultState: false,
					category: 'general',
				}),
				new Feature({
					label: 'Sort within playlists',
					key: 'sortWithinPlaylistsState',
					handler: () => Utils.log('Playlist sorting scope updated'),
					id: 'sortWithinPlaylistsToggle',
					defaultState: false,
					category: 'sorting',
				}),
				// Sort-by mode is rendered as a dropdown, not individual toggles.
				// See _addFeatureToggles → _createSortDropdown().
				new Feature({
					label: 'Mute by default',
					key: 'muteState',
					handler: () => {
						if (this._state.get('muteState')) {
							VideoPlayer.resetMuteState();
							VideoPlayer.mute(true);
						}
					},
					id: 'muteToggle',
					defaultState: false,
					category: 'player',
				}),
				new Feature({
					label: 'Sync Volume State',
					key: 'syncVolumeState',
					// No handler needed: App's 'visibilitychange' listener reads this directly.
					id: 'syncVolumeStateToggle',
					defaultState: true,
					category: 'player',
				}),
				new Feature({
					label: 'Hide cursor on video',
					key: 'cursorHideState',
					// Apply the CSS immediately when toggled, instead of waiting
					// for the next page load.
					handler: () => VideoPlayer.toggleCursorHide(this._state.get('cursorHideState')),
					id: 'cursorHideToggle',
					defaultState: false,
					category: 'player',
				}),
				new Feature({
					label: 'Enable download button',
					key: 'downloadButtonState',
					id: 'downloadButtonToggle',
					defaultState: true,
					category: 'player',
				}),
				new Feature({
					label: 'Hide watched videos',
					key: 'hideWatchedState',
					handler: () => this._eventEmitter.emit('hideVideos'),
					id: 'hideWatchedToggle',
					defaultState: false,
					category: 'byType',
				}),
				new Feature({
					label: 'Hide paid content',
					key: 'hidePaidContentState',
					handler: () => this._eventEmitter.emit('hideVideos'),
					id: 'hidePaidContentToggle',
					defaultState: true,
					category: 'byType',
				}),
				new Feature({
					label: 'Hide VR videos',
					key: 'hideVRState',
					handler: () => this._eventEmitter.emit('hideVideos'),
					id: 'hideVRToggle',
					defaultState: true,
					category: 'byType',
				}),
				new Feature({
					label: 'Hide Shorts section',
					key: 'hideShortsState',
					handler: () => this._eventEmitter.emit('hideVideos'),
					id: 'hideShortsToggle',
					defaultState: true,
					category: 'byType',
				}),
				new Feature({
					label: 'Default duration search',
					key: 'enforceSearchDurationState',
					handler: () => this._eventEmitter.emit('enforceSearchDuration'),
					id: 'enforceSearchDurationToggle',
					defaultState: false,
					category: 'duration',
				}),
				new Feature({
					label: 'Hide videos by duration',
					key: 'hideDurationOutOfRangeState',
					handler: () => this._eventEmitter.emit('hideVideos'),
					id: 'hideDurationOutOfRangeToggle',
					defaultState: false,
					category: 'duration',
				}),
				new Feature({
					label: 'Enable quick menu',
					key: 'quickMenuEnabledState',
					// Clears a previous in-session dismissal so re-enabling actually brings the
					// panel back. Re-renders explicitly since 'stateChanged' already fired.
					handler: () => {
						if (this._state.get('quickMenuEnabledState')) {
							sessionStorage.removeItem('phproQuickMenuDismissed');
							this._renderQuickMenu();
						}
					},
					id: 'quickMenuEnabledToggle',
					defaultState: false,
					category: 'general',
				}),
			];
		}

		_createSortDropdown(idOverride = null) {
			const row = Utils.createElement('div', {
				className: 'phpro-sort-row'
			});

			const selectId = idOverride || 'phpro-sort-select';

			const label = Utils.createElement('label', {
				textContent: 'Auto-sort:',
				for: selectId,
			});

			const select = Utils.createElement('select', {
				id: selectId,
				className: 'phpro-sort-select',
			});

			const options = [{
					value: 'none',
					label: 'Off'
				},
				{
					value: 'duration',
					label: 'By duration'
				},
				{
					value: 'award',
					label: 'By award'
				},
				{
					value: 'views',
					label: 'By views'
				},
			];

			const savedMode = Utils.getValidSortMode();

			for (const opt of options) {
				const el = Utils.createElement('option', {
					value: opt.value,
					textContent: opt.label
				});
				if (opt.value === savedMode) el.selected = true;
				select.appendChild(el);
			}

			select.addEventListener('change', () => {
				// Guard against a tampered dropdown value (e.g. via devtools).
				const mode = CONFIG.SORT.VALID_MODES.includes(select.value) ? select.value : 'none';
				CrossDomainStorage.setItem('sortModeState', mode);
				Utils.log(`Sort mode set to: ${mode}`);
				if (mode === 'duration') this._eventEmitter.emit('sortByDuration');
				else if (mode === 'award') this._eventEmitter.emit('sortByAward');
				else if (mode === 'views') this._eventEmitter.emit('sortByViews');
				// sortModeState is a string, not a boolean, so there is no 'stateChanged'
				// event to piggyback on. Rebuild the quick menu directly instead.
				this._renderQuickMenu();
			});

			row.appendChild(label);
			row.appendChild(select);
			return row;
		}

		_createCollapsible({ title, parent, storageKey, headerTag, headerClass }) {
			const isCollapsed = CrossDomainStorage.getItem(storageKey) === 'true';

			const header = Utils.createElement(headerTag, {
				className: headerClass,
				role: 'button',
				tabindex: '0',
				'aria-expanded': String(!isCollapsed),
			});

			const titleSpan = Utils.createElement('span', {
				textContent: title
			});
			const arrow = Utils.createElement('span', {
				textContent: isCollapsed ? '▸' : '▾',
				className: 'phpro-section-arrow',
				'aria-hidden': 'true',
			});

			header.appendChild(titleSpan);
			header.appendChild(arrow);

			const content = Utils.createElement('div', {
				className: 'phpro-section-content',
				style: {
					display: isCollapsed ? 'none' : 'block',
					width: '100%'
				},
			});

			const toggleSection = () => {
				const nowCollapsed = content.style.display !== 'none';
				content.style.display = nowCollapsed ? 'none' : 'block';
				arrow.textContent = nowCollapsed ? '▸' : '▾';
				header.setAttribute('aria-expanded', String(!nowCollapsed));
				CrossDomainStorage.setItem(storageKey, String(nowCollapsed));
			};

			header.addEventListener('click', toggleSection);
			header.addEventListener('keydown', (e) => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					toggleSection();
				}
			});

			parent.appendChild(header);
			parent.appendChild(content);

			// Register so updateToggleStates() can sync this section across tabs.
			this._sections.push({
				storageKey,
				content,
				arrow,
				header,
			});

			return content;
		}

		_createSection(title) {
			return this._createCollapsible({
				title,
				parent: this._menu,
				storageKey: `phpro_section_${title.replace(/\s+/g, '_').toLowerCase()}_collapsed`,
				headerTag: 'h3',
				headerClass: 'phpro-category-header phpro-section-header',
			});
		}

		_createSubSection(parentContent, parentTitle, title) {
			const storageKey =
				`phpro_subsection_${parentTitle}_${title}`.replace(/\s+/g, '_').toLowerCase() + '_collapsed';
			return this._createCollapsible({
				title,
				parent: parentContent,
				storageKey,
				headerTag: 'h4',
				headerClass: 'phpro-subsection-header',
			});
		}

		_addCloseFooterButton() {
			const button = Utils.createElement('button', {
				type: 'button',
				'aria-label': 'Close settings menu',
				textContent: 'Close menu',
				style: {
					marginTop: '10px',
					marginBottom: '0',
					padding: '10px 12px',
					backgroundColor: 'orange',
					color: 'black',
					border: '1px solid orange',
					borderRadius: '10px',
					cursor: 'pointer',
					transition: 'all 0.25s',
					width: '100%',
					fontSize: '13px',
					fontWeight: 'bold',
					fontFamily: 'inherit',
				},
			});

			button.addEventListener('mouseenter', () => {
				button.style.backgroundColor = 'black';
				button.style.color = 'orange';
			});
			button.addEventListener('mouseleave', () => {
				button.style.backgroundColor = 'orange';
				button.style.color = 'black';
			});

			button.addEventListener('click', (e) => {
				e.stopPropagation();
				this._hide();
			});

			this._menu.appendChild(button);
		}

		_fillSectionContent(content, category, extras) {
			for (const builder of extras) {
				if (builder.position === 'before') builder.fn(content);
			}
			if (category) {
				for (const feature of this._features.filter(f => f.category === category)) {
					content.appendChild(
						feature.key === 'quickMenuEnabledState' ?
						this._createQuickMenuToggleBlock(feature) :
						this._createToggleRow(feature)
					);
				}
			}
			for (const builder of extras) {
				if (builder.position !== 'before') builder.fn(content);
			}
		}

		_addSection(title, category, ...extras) {
			const content = this._createSection(title);
			this._fillSectionContent(content, category, extras);
			return content;
		}

		_addSubSection(parentContent, parentTitle, title, category, ...extras) {
			const content = this._createSubSection(parentContent, parentTitle, title);
			this._fillSectionContent(content, category, extras);
			return content;
		}

		_appendManualSortButtons(content) {
			const manualButtons = [{
					text: 'Sort by duration manually',
					handler: () => { this._eventEmitter.emit('sortByDuration', true); this._scrollToTop.scrollToTop(); }
				},
				{
					text: 'Sort by views manually',
					handler: () => { this._eventEmitter.emit('sortByViews', true); this._scrollToTop.scrollToTop(); }
				},
				{
					text: 'Put award first manually',
					handler: () => { this._eventEmitter.emit('sortByAward', true); this._scrollToTop.scrollToTop(); }
				},
			];
			for (const { text, handler } of manualButtons) {
				content.appendChild(this._createActionButton(text, handler));
			}
		}

		// Appends the autoscroll and scroll-to-top buttons to a section.
		_appendScrollButtons(content) {
			content.appendChild(this._createAutoscrollButton());
			content.appendChild(this._createScrollToTopButton());
		}

		static get _FILTER_TYPES() {
			return {
				hide: {
					storageKey: 'savedFilterWords',
					inputId: 'inputFilterWords',
					label: 'Hide videos matching:',
					chipBg: '#ff9800', // orange
					chipColor: 'black',
					ariaVerb: 'filter',
				},
				show: {
					storageKey: 'savedShowWords',
					inputId: 'inputShowWords',
					label: 'Show only matching:',
					chipBg: '#1565c0', // blue, to distinguish from the orange hide chips
					chipColor: 'white',
					ariaVerb: 'show-only',
				},
			};
		}

		_populateFilterWords(content) {
			for (const type of Object.keys(MenuManager._FILTER_TYPES)) {
				content.appendChild(this._buildFilterBlock(type));
			}
		}

		_populateDurationFilter(content) {
			const container = Utils.createElement('div', {
				style: {
					marginTop: '10px',
					width: '100%',
					display: 'flex',
					flexDirection: 'column',
				},
			});

			const label = Utils.createElement('label', {
				textContent: 'Duration in min:',
				style: {
					color: 'white',
					display: 'block',
					marginBottom: '10px',
					fontSize: '14px',
				},
			});

			container.appendChild(label);
			container.appendChild(this._createDurationSlider());
			content.appendChild(container);
		}

		_createDurationSlider() {
			const STOPS = [0, 10, 20, 30, 40];
			const lastIndex = STOPS.length - 1;

			const wrap = Utils.createElement('div', {
				className: 'phpro-duration-slider-wrap',
			});
			const slider = Utils.createElement('div', {
				className: 'phpro-duration-slider',
			});
			const track = Utils.createElement('div', {
				className: 'phpro-duration-slider-track',
			});
			const range = Utils.createElement('div', {
				className: 'phpro-duration-slider-range',
			});
			const minThumb = Utils.createElement('div', {
				className: 'phpro-duration-slider-thumb',
				role: 'slider',
				tabindex: '0',
				'aria-label': 'Minimum duration',
				'aria-valuemin': '0',
				'aria-valuemax': '40',
			});
			const maxThumb = Utils.createElement('div', {
				className: 'phpro-duration-slider-thumb',
				role: 'slider',
				tabindex: '0',
				'aria-label': 'Maximum duration',
				'aria-valuemin': '0',
				'aria-valuemax': '40',
			});
			slider.appendChild(track);
			slider.appendChild(range);
			slider.appendChild(minThumb);
			slider.appendChild(maxThumb);
			wrap.appendChild(slider);

			const labelsRow = Utils.createElement('div', {
				className: 'phpro-duration-slider-labels',
			});
			for (const stop of STOPS) {
				labelsRow.appendChild(Utils.createElement('span', {
					textContent: stop === STOPS[lastIndex] ? `${stop}+` : String(stop),
				}));
			}
			wrap.appendChild(labelsRow);

			const minutesToIndex = (minutes, isMax) => {
				if (minutes === null) return isMax ? lastIndex : 0;
				let closest = 0;
				let closestDiff = Infinity;
				STOPS.forEach((stop, i) => {
					const diff = Math.abs(stop - minutes);
					if (diff < closestDiff) {
						closestDiff = diff;
						closest = i;
					}
				});
				return closest;
			};
			// Converts a stop index back into a saved minutes value - null at
			// either "no bound" end, otherwise the literal stop value.
			const indexToMinutes = (index, isMax) => {
				if (index === 0 && !isMax) return null;
				if (index === lastIndex && isMax) return null;
				return STOPS[index];
			};
			const percentForIndex = (index) => (index / lastIndex) * 100;

			const { min, max } = DurationFilter.getBounds();
			let minIndex = minutesToIndex(min, false);
			let maxIndex = minutesToIndex(max, true);
			if (minIndex > maxIndex) minIndex = maxIndex;

			const render = () => {
				const minPct = percentForIndex(minIndex);
				const maxPct = percentForIndex(maxIndex);
				minThumb.style.left = `${minPct}%`;
				maxThumb.style.left = `${maxPct}%`;
				range.style.left = `${minPct}%`;
				range.style.width = `${maxPct - minPct}%`;
				minThumb.setAttribute('aria-valuenow', String(STOPS[minIndex]));
				maxThumb.setAttribute('aria-valuenow', String(STOPS[maxIndex]));
			};
			render();

			const commit = () => {
				DurationFilter.setMin(indexToMinutes(minIndex, false) ?? '');
				DurationFilter.setMax(indexToMinutes(maxIndex, true) ?? '');
				const minLabel = minIndex === 0 ? 'off' : `${STOPS[minIndex]}min`;
				const maxLabel = maxIndex === lastIndex ? 'off' : `${STOPS[maxIndex]}min`;
				Utils.log(`Duration range saved: min=${minLabel}, max=${maxLabel}`);
				this._eventEmitter.emit('hideVideos');
				this._eventEmitter.emit('enforceSearchDuration');
			};

			let dragging = null; // 'min' | 'max' | null

			const clientXToIndex = (clientX) => {
				const rect = slider.getBoundingClientRect();
				const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
				return Math.round(ratio * lastIndex);
			};

			const onMove = (clientX) => {
				const index = clientXToIndex(clientX);
				if (dragging === 'min') minIndex = Math.min(index, maxIndex);
				else if (dragging === 'max') maxIndex = Math.max(index, minIndex);
				render();
			};

			const startDrag = (which) => (e) => {
				e.preventDefault();
				dragging = which;
			};
			minThumb.addEventListener('mousedown', startDrag('min'));
			maxThumb.addEventListener('mousedown', startDrag('max'));
			minThumb.addEventListener('touchstart', startDrag('min'), {
				passive: false
			});
			maxThumb.addEventListener('touchstart', startDrag('max'), {
				passive: false
			});

			const stopDrag = () => {
				if (!dragging) return;
				dragging = null;
				commit();
			};

			document.addEventListener('mousemove', (e) => {
				if (dragging) onMove(e.clientX);
			});
			document.addEventListener('mouseup', stopDrag);
			document.addEventListener('touchmove', (e) => {
				if (dragging && e.touches[0]) onMove(e.touches[0].clientX);
			}, {
				passive: true
			});
			document.addEventListener('touchend', stopDrag);

			const onKey = (which) => (e) => {
				if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
				e.preventDefault();
				const delta = e.key === 'ArrowLeft' ? -1 : 1;
				if (which === 'min') {
					minIndex = Math.min(Math.max(0, minIndex + delta), maxIndex);
				} else {
					maxIndex = Math.max(Math.min(lastIndex, maxIndex + delta), minIndex);
				}
				render();
				commit();
			};
			minThumb.addEventListener('keydown', onKey('min'));
			maxThumb.addEventListener('keydown', onKey('max'));

			// Kept so updateToggleStates() can re-sync the handles if another
			// tab changes the saved values while this tab is in the background.
			this._durationSliderRefs = {
				resync: () => {
					const bounds = DurationFilter.getBounds();
					minIndex = minutesToIndex(bounds.min, false);
					maxIndex = minutesToIndex(bounds.max, true);
					if (minIndex > maxIndex) minIndex = maxIndex;
					render();
				},
			};

			return wrap;
		}

		get _quickMenuActions() {
			return [{
					id: 'action_sortDuration',
					label: 'Sort by duration manually',
					handler: () => {
						this._eventEmitter.emit('sortByDuration', true);
						this._scrollToTop.scrollToTop();
					},
				},
				{
					id: 'action_sortViews',
					label: 'Sort by views manually',
					handler: () => {
						this._eventEmitter.emit('sortByViews', true);
						this._scrollToTop.scrollToTop();
					},
				},
				{
					id: 'action_sortAward',
					label: 'Put award first manually',
					handler: () => {
						this._eventEmitter.emit('sortByAward', true);
						this._scrollToTop.scrollToTop();
					},
				},
				{
					id: 'action_autoscroll',
					label: 'Start/stop autoscroll',
					handler: () => this._autoScroller.toggle(),
				},
				{
					id: 'action_scrollTop',
					label: 'Scroll to top of the page',
					handler: () => this._scrollToTop.scrollToTop(),
				},
			];
		}

		_getQuickMenuItemIds() {
			const raw = CrossDomainStorage.getItem('phpro_quickMenuItems') ?? '';
			return raw.split(',').map(s => s.trim()).filter(Boolean);
		}

		_setQuickMenuItemIds(ids) {
			CrossDomainStorage.setItem('phpro_quickMenuItems', ids.join(','));
		}

		_isQuickMenuItemSelected(id) {
			return this._getQuickMenuItemIds().includes(id);
		}

		_toggleQuickMenuItem(id, include) {
			const ids = this._getQuickMenuItemIds();
			const idx = ids.indexOf(id);
			if (include && idx === -1) ids.push(id);
			else if (!include && idx !== -1) ids.splice(idx, 1);
			this._setQuickMenuItemIds(ids);
			this._renderQuickMenu();
		}

		_createQuickMenuCheckboxRow(id, label, isChecked, onChange) {
			const row = Utils.createElement('label', {
				style: {
					display: 'flex',
					alignItems: 'center',
					gap: '8px',
					marginBottom: '6px',
					fontSize: '13px',
					color: 'white',
					cursor: 'pointer',
				},
			});

			const checkbox = Utils.createElement('input', {
				type: 'checkbox',
				className: 'phpro-checkbox',
			});
			checkbox.checked = isChecked;
			checkbox.addEventListener('change', () => onChange(checkbox.checked));

			const text = Utils.createElement('span', {
				textContent: label
			});

			row.appendChild(checkbox);
			row.appendChild(text);
			return row;
		}

		_populateQuickMenuChooser(content) {
			const sectionHeading = (text) => Utils.createElement('div', {
				textContent: text,
				style: {
					color: '#999',
					fontSize: '11px',
					fontWeight: 'bold',
					textTransform: 'uppercase',
					letterSpacing: '0.5px',
					marginBottom: '6px',
				},
			});

			content.appendChild(sectionHeading('Toggles for Quick Menu'));
			for (const feature of this._features) {
				if (feature.key === 'quickMenuEnabledState') continue;
				content.appendChild(
					this._createQuickMenuCheckboxRow(
						feature.key,
						feature.label,
						this._isQuickMenuItemSelected(feature.key),
						(checked) => this._toggleQuickMenuItem(feature.key, checked)
					)
				);
			}

			const actionHeading = sectionHeading('Buttons for Quick Menu');
			actionHeading.style.marginTop = '14px';
			actionHeading.style.borderTop = '1px solid #444';
			actionHeading.style.paddingTop = '10px';
			content.appendChild(actionHeading);
			for (const action of this._quickMenuActions) {
				content.appendChild(
					this._createQuickMenuCheckboxRow(
						action.id,
						action.label,
						this._isQuickMenuItemSelected(action.id),
						(checked) => this._toggleQuickMenuItem(action.id, checked)
					)
				);
			}

			const dropdownHeading = sectionHeading('Dropdowns for Quick Menu');
			dropdownHeading.style.marginTop = '14px';
			dropdownHeading.style.borderTop = '1px solid #444';
			dropdownHeading.style.paddingTop = '10px';
			content.appendChild(dropdownHeading);
			content.appendChild(
				this._createQuickMenuCheckboxRow(
					'dropdown_sortMode',
					'Auto-sort dropdown',
					this._isQuickMenuItemSelected('dropdown_sortMode'),
					(checked) => this._toggleQuickMenuItem('dropdown_sortMode', checked)
				)
			);
		}

		static get _GEAR_ICON_SVG() {
			return `<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true" focusable="false">
				<circle cx="10" cy="10" r="5.4" fill="orange"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange" transform="rotate(60 10 10)"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange" transform="rotate(120 10 10)"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange" transform="rotate(180 10 10)"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange" transform="rotate(240 10 10)"/>
				<rect x="8.3" y="0.8" width="3.4" height="5" rx="0.6" fill="orange" transform="rotate(300 10 10)"/>
				<circle cx="10" cy="10" r="2.3" fill="#161616"/>
			</svg>`;
		}

		_createQuickMenuToggleBlock(feature) {
			const wrapper = Utils.createElement('div', {
				style: {
					width: '100%'
				},
			});

			const row = this._createToggleRow(feature);
			row.style.width = '100%';

			const isCollapsed = true;

			const gearWrap = Utils.createElement('span', {
				className: 'phpro-quickmenu-gear-wrap',
				role: 'button',
				tabindex: '0',
				'aria-label': 'Choose quick menu items',
				'aria-expanded': String(!isCollapsed),
			});
			const gearIcon = Utils.createElement('span', {
				className: 'phpro-quickmenu-gear' + (isCollapsed ? '' : ' phpro-quickmenu-gear--open'),
			});
			gearIcon.innerHTML = MenuManager._GEAR_ICON_SVG;
			const tooltip = Utils.createElement('span', {
				className: 'phpro-quickmenu-tooltip',
				role: 'tooltip',
				textContent: 'Configure quick menu',
			});
			gearWrap.appendChild(gearIcon);
			gearWrap.appendChild(tooltip);

			const chooserContent = Utils.createElement('div', {
				className: 'phpro-section-content',
				style: {
					display: isCollapsed ? 'none' : 'block',
					width: '100%',
					marginTop: '8px',
				},
			});
			this._populateQuickMenuChooser(chooserContent);

			const toggleChooser = () => {
				const nowCollapsed = chooserContent.style.display !== 'none';
				chooserContent.style.display = nowCollapsed ? 'none' : 'block';
				gearIcon.classList.toggle('phpro-quickmenu-gear--open', !nowCollapsed);
				gearWrap.setAttribute('aria-expanded', String(!nowCollapsed));
			};
			gearWrap.addEventListener('click', (e) => {
				e.stopPropagation();
				toggleChooser();
			});
			gearWrap.addEventListener('keydown', (e) => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					toggleChooser();
				}
			});

			row.appendChild(gearWrap);
			wrapper.appendChild(row);
			wrapper.appendChild(chooserContent);

			// Kept (in memory only, nothing persisted) so _hide() can force
			// this back to collapsed when the settings panel closes.
			this._quickMenuChooserRefs = {
				gearWrap,
				gearIcon,
				content: chooserContent,
			};

			return wrapper;
		}

		_collapseQuickMenuChooser() {
			const refs = this._quickMenuChooserRefs;
			if (!refs) return;
			refs.content.style.display = 'none';
			refs.gearIcon.classList.remove('phpro-quickmenu-gear--open');
			refs.gearWrap.setAttribute('aria-expanded', 'false');
		}

		_renderQuickMenu() {
			try {
				this._quickMenuPanel?.remove();
				this._quickMenuPanel = null;
				clearTimeout(this._quickMenuFadeTimer);

				if (!this._state.get('quickMenuEnabledState')) {
					this._setToggleButtonHidden(false);
					return;
				}
				if (sessionStorage.getItem('phproQuickMenuDismissed') === 'true') {
					this._setToggleButtonHidden(false);
					return;
				}

				const ids = this._getQuickMenuItemIds();
				if (ids.length === 0) {
					this._setToggleButtonHidden(false);
					return;
				}

				const body = Utils.createElement('div', {
					className: 'phpro-quick-menu-body',
				});

				for (const id of ids) {
					if (id === 'dropdown_sortMode') {
						body.appendChild(this._createSortDropdown('phpro-qm-sort-select'));
						continue;
					}
					const feature = this._features.find(f => f.key === id);
					if (feature) {
						const row = this._createToggleRow(feature, `phpro-qm-${feature.id}`);
						const labelEl = row.querySelector('span');
						if (labelEl) {
							labelEl.style.width = 'auto';
							labelEl.style.whiteSpace = 'normal';
						}
						body.appendChild(row);
						continue;
					}
					const action = this._quickMenuActions.find(a => a.id === id);
					if (action) {
						body.appendChild(this._createActionButton(action.label, action.handler));
					}
					// Silently skip ids that no longer match anything (e.g. a
					// feature removed in a later version) - nothing to render.
				}

				if (!body.hasChildNodes()) {
					this._setToggleButtonHidden(false);
					return;
				}

				const panel = Utils.createElement('div', {
					id: 'phproQuickMenu',
					className: 'phpro-quick-menu',
				});

				const header = Utils.createElement('div', {
					className: 'phpro-quick-menu-header',
				});
				header.appendChild(Utils.createElement('span', {
					textContent: 'Quick menu',
					style: {
						fontWeight: 'bold',
						color: 'orange'
					},
				}));
				const dismissBtn = Utils.createElement('button', {
					type: 'button',
					'aria-label': 'Dismiss quick menu',
					textContent: '×',
					className: 'phpro-quick-menu-dismiss',
				});
				dismissBtn.addEventListener('click', () => {
					sessionStorage.setItem('phproQuickMenuDismissed', 'true');
					this._state.set('quickMenuEnabledState', false);
				});
				header.appendChild(dismissBtn);

				panel.appendChild(header);
				panel.appendChild(body);

				const footerDivider = Utils.createElement('div', {
					style: {
						borderTop: '1px solid rgba(255,255,255,0.15)',
						margin: '10px 0 8px',
					},
				});
				const openSettingsBtn = Utils.createElement('button', {
					type: 'button',
					textContent: 'Open Settings',
					className: 'phpro-quick-menu-open-settings',
				});
				openSettingsBtn.addEventListener('click', () => this._togglePanel(true));
				panel.appendChild(footerDivider);
				panel.appendChild(openSettingsBtn);

				this._wireQuickMenuDrag(panel, header);
				document.documentElement.appendChild(panel);
				this._quickMenuPanel = panel;
				// Needs the panel in the DOM first - it reads a real bounding
				// rect to clamp against the current viewport.
				this._applyQuickMenuPosition(panel);
				this._setToggleButtonHidden(true);

				panel.classList.add('phpro-quick-menu-faded');
				panel.addEventListener('mouseenter', () => {
					clearTimeout(this._quickMenuFadeTimer);
					panel.classList.remove('phpro-quick-menu-faded');
				});
				panel.addEventListener('mouseleave', () => {
					clearTimeout(this._quickMenuFadeTimer);
					this._quickMenuFadeTimer = setTimeout(() => {
						panel.classList.add('phpro-quick-menu-faded');
					}, CONFIG.TIMING.QUICK_MENU_FADE_DELAY_MS);
				});
			} catch (err) {
				handleError('MenuManager._renderQuickMenu', err);
			}
		}

		_setToggleButtonHidden(hidden) {
			if (!this._toggleButton) return;
			this._toggleButton.style.display = hidden ? 'none' : '';
		}

		_wireQuickMenuDrag(panel, header) {
			const MIN_DISTANCE = 8;
			let startX = 0,
				startY = 0,
				startLeft = 0,
				startTop = 0,
				moved = false;

			const onMove = (clientX, clientY) => {
				const dx = clientX - startX;
				const dy = clientY - startY;
				if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) moved = true;
				if (!moved) return;

				const rect = panel.getBoundingClientRect();
				const vw = window.innerWidth;
				const vh = window.innerHeight;
				const left = Math.max(MIN_DISTANCE, Math.min(startLeft + dx, vw - rect.width - MIN_DISTANCE));
				const top = Math.max(MIN_DISTANCE, Math.min(startTop + dy, vh - rect.height - MIN_DISTANCE));
				panel.style.left = `${left}px`;
				panel.style.top = `${top}px`;
			};
			const onMouseMove = (e) => onMove(e.clientX, e.clientY);
			const onTouchMove = (e) => {
				const touch = e.touches[0];
				if (touch) onMove(touch.clientX, touch.clientY);
			};

			const endDrag = () => {
				document.removeEventListener('mousemove', onMouseMove);
				document.removeEventListener('mouseup', endDrag);
				document.removeEventListener('touchmove', onTouchMove);
				document.removeEventListener('touchend', endDrag);
				if (moved) this._saveQuickMenuPosition(panel);
			};

			const startDrag = (clientX, clientY, target) => {
				if (target?.closest?.('.phpro-quick-menu-dismiss')) return;
				moved = false;
				const rect = panel.getBoundingClientRect();
				startX = clientX;
				startY = clientY;
				startLeft = rect.left;
				startTop = rect.top;
				// Switches from the CSS default (bottom/right) to explicit
				// left/top so the drag can move it freely from here on.
				panel.style.right = 'auto';
				panel.style.bottom = 'auto';
				panel.style.left = `${rect.left}px`;
				panel.style.top = `${rect.top}px`;
				document.addEventListener('mousemove', onMouseMove);
				document.addEventListener('mouseup', endDrag);
				document.addEventListener('touchmove', onTouchMove, {
					passive: true
				});
				document.addEventListener('touchend', endDrag);
			};

			header.addEventListener('mousedown', (e) => {
				if (e.target.closest('.phpro-quick-menu-dismiss')) return;
				e.preventDefault();
				startDrag(e.clientX, e.clientY, e.target);
			});
			header.addEventListener('touchstart', (e) => {
				const touch = e.touches[0];
				if (touch) startDrag(touch.clientX, touch.clientY, e.target);
			}, {
				passive: true
			});

			header.style.cursor = 'move';
		}

		_applyQuickMenuPosition(panel) {
			try {
				const raw = CrossDomainStorage.getItem('phpro_quickMenuPos');
				if (!raw) return;
				const data = JSON.parse(raw);
				const rect = panel.getBoundingClientRect();
				const vw = window.innerWidth;
				const vh = window.innerHeight;
				const MIN_DISTANCE = 8;

				let left = data.horizontal === 'left' ?
					data.hDistance :
					vw - rect.width - data.hDistance;
				let top = data.vertical === 'top' ?
					data.vDistance :
					vh - rect.height - data.vDistance;

				left = Math.max(MIN_DISTANCE, Math.min(left, vw - rect.width - MIN_DISTANCE));
				top = Math.max(MIN_DISTANCE, Math.min(top, vh - rect.height - MIN_DISTANCE));

				panel.style.left = `${left}px`;
				panel.style.top = `${top}px`;
				panel.style.right = 'auto';
				panel.style.bottom = 'auto';
			} catch (err) {
				handleError('MenuManager._applyQuickMenuPosition', err, 'warn');
			}
		}

		_saveQuickMenuPosition(panel) {
			try {
				const rect = panel.getBoundingClientRect();
				const vw = window.innerWidth;
				const vh = window.innerHeight;
				const distLeft = rect.left;
				const distRight = vw - rect.right;
				const distTop = rect.top;
				const distBottom = vh - rect.bottom;

				CrossDomainStorage.setItem('phpro_quickMenuPos', JSON.stringify({
					horizontal: distLeft < distRight ? 'left' : 'right',
					vertical: distTop < distBottom ? 'top' : 'bottom',
					hDistance: Math.min(distLeft, distRight),
					vDistance: Math.min(distTop, distBottom),
				}));
				Utils.log('Quick menu position saved');
			} catch (err) {
				handleError('MenuManager._saveQuickMenuPosition', err, 'warn');
			}
		}

		_buildFilterBlock(type) {
			const cfg = MenuManager._FILTER_TYPES[type];

			const container = Utils.createElement('div', {
				style: {
					marginTop: '10px',
					width: '100%',
					display: 'flex',
					flexDirection: 'column'
				},
			});

			const label = Utils.createElement('label', {
				textContent: cfg.label,
				for: cfg.inputId,
				style: {
					color: 'white',
					display: 'block',
					marginBottom: '6px',
					fontSize: '14px'
				},
			});

			const input = Utils.createElement('input', {
				type: 'text',
				id: cfg.inputId,
				placeholder: 'Type word(s) and press Enter or , to add',
				style: {
					display: 'block',
					padding: '8px 12px',
					border: '1px solid #666',
					borderRadius: '5px',
					fontSize: '14px',
					backgroundColor: '#222',
					color: 'white',
					width: '100%',
					boxSizing: 'border-box',
				},
			});

			const tagsContainer = Utils.createElement('div', {
				style: {
					display: 'flex',
					flexWrap: 'wrap',
					gap: '6px',
					marginTop: '8px',
					minHeight: '0px'
				},
			});

			// Keep references so updateToggleStates() can re-render chips on tab focus
			// and so the add/remove handlers can clear the input.
			this._filterRefs[type] = {
				input,
				tagsContainer
			};

			container.appendChild(label);
			container.appendChild(input);
			container.appendChild(tagsContainer);

			this._renderFilterTags(type);

			const commit = () => {
				const value = input.value.trim();
				if (value) this._addFilterWord(type, value);
			};
			input.addEventListener('keydown', (e) => {
				if (e.key === 'Enter' || e.key === ',') {
					e.preventDefault();
					commit();
				}
			});
			input.addEventListener('blur', commit);

			return container;
		}

		_getFilterWords(type) {
			const cfg = MenuManager._FILTER_TYPES[type];
			const saved = CrossDomainStorage.getItem(cfg.storageKey) ?? '';
			return Utils.sanitizeFilterWords(saved);
		}

		// Writes the word list for a filter type back to storage.
		_saveFilterWords(type, wordsArray) {
			const cfg = MenuManager._FILTER_TYPES[type];
			CrossDomainStorage.setItem(cfg.storageKey, wordsArray.join(', '));
		}

		// Adds a word to a filter type (if new), persists it, re-renders chips,
		//       clears the input, and triggers a hide pass.
		_addFilterWord(type, word) {
			word = word.trim().toLowerCase();
			if (!word) return;

			const words = this._getFilterWords(type);
			const input = this._filterRefs[type]?.input;
			if (words.includes(word)) {
				if (input) input.value = '';
				return;
			}

			words.push(word);
			this._saveFilterWords(type, words);
			this._renderFilterTags(type);
			if (input) input.value = '';
			this._eventEmitter.emit('hideVideos');
		}

		// Removes a word from a filter type, persists, re-renders, re-filters.
		_removeFilterWord(type, word) {
			const words = this._getFilterWords(type).filter(w => w !== word);
			this._saveFilterWords(type, words);
			this._renderFilterTags(type);
			this._eventEmitter.emit('hideVideos');
		}

		_renderFilterTags(type) {
			const refs = this._filterRefs[type];
			if (!refs?.tagsContainer) return;
			refs.tagsContainer.innerHTML = '';
			for (const word of this._getFilterWords(type)) {
				refs.tagsContainer.appendChild(this._createFilterChip(type, word));
			}
		}

		_createFilterChip(type, word) {
			const cfg = MenuManager._FILTER_TYPES[type];

			const tag = Utils.createElement('div', {
				style: {
					display: 'inline-flex',
					alignItems: 'center',
					backgroundColor: cfg.chipBg,
					color: cfg.chipColor,
					padding: '4px 10px',
					borderRadius: '20px',
					fontSize: '13px',
					fontWeight: 'bold',
					whiteSpace: 'nowrap',
					boxShadow: '0 2px 4px rgba(0,0,0,0.3)',
				},
			});

			const text = Utils.createElement('span', {
				textContent: word,
				style: {
					marginRight: '6px'
				},
			});

			const removeBtn = Utils.createElement('span', {
				textContent: '×',
				role: 'button',
				tabindex: '0',
				'aria-label': `Remove ${cfg.ariaVerb} word ${word}`,
				style: {
					cursor: 'pointer',
					fontSize: '16px',
					fontWeight: 'bold',
					lineHeight: '1',
					padding: '0 2px',
				},
			});

			const doRemove = (e) => {
				e.stopPropagation();
				this._removeFilterWord(type, word);
			};
			removeBtn.addEventListener('click', doRemove);
			removeBtn.addEventListener('keydown', (e) => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					doRemove(e);
				}
			});

			tag.appendChild(text);
			tag.appendChild(removeBtn);
			return tag;
		}

		_createToggleRow(feature, idOverride = null) {
			const container = Utils.createElement('div', {
				style: {
					display: 'flex',
					alignItems: 'center',
					marginBottom: '10px',
					width: '100%'
				},
			});

			const isActive = this._state.get(feature.key, feature.defaultState);

			const track = Utils.createElement('div', {
				id: idOverride || feature.id,
				className: 'phpro-toggle-track',
				role: 'switch',
				tabindex: '0',
				'aria-checked': String(isActive),
				'aria-label': feature.label,
				style: {
					position: 'relative',
					width: '40px',
					height: '20px',
					backgroundColor: isActive ? 'orange' : '#666',
					borderRadius: '20px',
					cursor: 'pointer',
					transition: 'background-color 0.2s',
					flexShrink: '0',
					border: '1px solid white',
				},
			});

			const thumb = Utils.createElement('div', {
				style: {
					position: 'absolute',
					left: isActive ? '22px' : '2px',
					top: '50%',
					transform: 'translateY(-50%)',
					width: '16px',
					height: '16px',
					backgroundColor: 'white',
					borderRadius: '50%',
					transition: 'left 0.2s',
					boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
				},
			});
			track.appendChild(thumb);

			const labelEl = Utils.createElement('span', {
				textContent: feature.label,
				style: {
					color: 'white',
					marginLeft: '12px',
					fontSize: '13px',
					lineHeight: '20px',
					cursor: 'pointer',
					width: 'max-content',
				},
			});

			const onActivate = () => this._handleToggleClick(feature, track, thumb);
			track.addEventListener('click', onActivate);
			labelEl.addEventListener('click', onActivate);
			track.addEventListener('keydown', (e) => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					onActivate();
				}
			});

			container.appendChild(track);
			container.appendChild(labelEl);
			return container;
		}

		// Creates the draggable floating "☰ Menu" button.
		// See the MenuManager class comment for the full drag/tap event flow.
		_createToggleButton() {
			const isTransparent = this._state.get('opaqueMenuButtonState', false);

			const button = Utils.createElement('div', {
				id: 'menuToggle',
				role: 'button',
				tabindex: '0',
				'aria-label': 'Open Pornhub Pro-ish settings',
				textContent: '☰ Menu',
				style: {
					position: 'fixed',
					left: '12px',
					top: '12px',
					fontSize: '13px',
					color: 'orange',
					cursor: 'grab',
					zIndex: '999999998',
					padding: '8px 15px',
					backgroundColor: 'rgba(0, 0, 0, 0.9)',
					border: '2px solid orange',
					borderRadius: '9999px',
					fontWeight: 'bold',
					fontFamily: 'Arial, sans-serif',
					userSelect: 'none',
					boxShadow: '0 4px 15px rgba(0,0,0,0.7)',
					transition: 'background-color 0.25s, opacity 0.6s',
					willChange: 'transform',
					whiteSpace: 'nowrap',
					display: 'inline-flex',
					alignItems: 'center',
					justifyContent: 'center',
					minWidth: 'auto',
					boxSizing: 'border-box',
				},
			});

			const applyInitialPosition = () => {
				this._applySavedPosition(button, false);
			};

			applyInitialPosition();
			requestAnimationFrame(applyInitialPosition);
			window.addEventListener('load', applyInitialPosition, {
				once: true
			});

			button.addEventListener('click', e => {
				if (this._wasDragged) return;
				e.stopPropagation();
				this._togglePanel();
			});

			// Keyboard activation (Enter/Space)
			button.addEventListener('keydown', e => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					this._togglePanel();
				}
			});

			button.addEventListener('mouseenter', () => {
				this._cancelButtonFade();
				button.style.backgroundColor = this._state.get('opaqueMenuButtonState')
					? 'rgba(122, 59, 0, 0.5)'
					: '#7a3b00';
			});

			button.addEventListener('mouseleave', () => {
				button.style.backgroundColor = this._state.get('opaqueMenuButtonState')
					? 'rgba(0, 0, 0, 0.3)'
					: 'rgba(0, 0, 0, 0.9)';
				this._scheduleButtonFade();
			});

			let isDragging = false;
			let rafId = null;
			let startX = 0,
				startY = 0;
			let baseLeft = 0,
				baseTop = 0;
			let currentTx = 0,
				currentTy = 0;
			this._wasDragged = false;

			const SNAP_MARGIN = 90;

			const snapToEdge = (rawLeft, rawTop) => {
				const vw = window.innerWidth;
				const vh = window.innerHeight;
				const rect = button.getBoundingClientRect();
				const w = rect.width;
				const h = rect.height;

				let finalLeft = rawLeft;
				let finalTop = rawTop;

				if (rawLeft < SNAP_MARGIN) finalLeft = 12;
				else if (rawLeft > vw - w - SNAP_MARGIN) finalLeft = vw - w - 12;

				if (rawTop < SNAP_MARGIN) finalTop = 12;
				else if (rawTop > vh - h - SNAP_MARGIN) finalTop = vh - h - 12;

				return {
					left: finalLeft,
					top: finalTop
				};
			};

			const applyTransform = () => {
				button.style.transform = `translate(${currentTx}px, ${currentTy}px)`;
			};

			// Drag threshold: mouse is precise (5px), touch needs more
			// room to avoid normal finger wobble being mistaken for a drag
			const MOUSE_DRAG_THRESHOLD = 5;
			const TOUCH_DRAG_THRESHOLD = 15;

			const onDragStart = (clientX, clientY) => {
				this._wasDragged = false;
				isDragging = false;
				startX = clientX;
				startY = clientY;

				const rect = button.getBoundingClientRect();
				button.style.width = `${rect.width}px`;
				button.style.height = `${rect.height}px`;

				baseLeft = parseFloat(getComputedStyle(button).left) || 12;
				baseTop = parseFloat(getComputedStyle(button).top) || 12;

				button.style.transition = 'none';
				button.style.transform = 'translate(0px, 0px)';
			};

			const onDragMove = (clientX, clientY, threshold) => {
				const dx = clientX - startX;
				const dy = clientY - startY;

				if (!isDragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) {
					isDragging = true;
					this._wasDragged = true;
					button.style.cursor = 'grabbing';
				}

				if (isDragging) {
					currentTx = dx;
					currentTy = dy;
					if (rafId === null) {
						rafId = requestAnimationFrame(() => {
							applyTransform();
							rafId = null;
						});
					}
				}
			};

			const onDragEnd = () => {
				if (rafId) {
					cancelAnimationFrame(rafId);
					rafId = null;
				}

				if (isDragging) {
					const rawLeft = baseLeft + currentTx;
					const rawTop = baseTop + currentTy;
					const snapped = snapToEdge(rawLeft, rawTop);

					button.style.width = '';
					button.style.height = '';
					button.style.transition = 'opacity 0.25s, background-color 0.25s';
					button.style.left = `${snapped.left}px`;
					button.style.top = `${snapped.top}px`;
					button.style.transform = 'translate(0px, 0px)';

					this._saveButtonPosition(button);
				}

				button.style.cursor = 'grab';
				button.style.backgroundColor = 'rgba(0, 0, 0, 0.9)';
				isDragging = false;
			};

			// --- Mouse drag ---
			button.addEventListener('mousedown', e => {
				if (e.button !== 0) return;
				onDragStart(e.clientX, e.clientY);

				const onMouseMove = (moveEvent) => onDragMove(moveEvent.clientX, moveEvent.clientY, MOUSE_DRAG_THRESHOLD);
				const onMouseUp = () => {
					document.removeEventListener('mousemove', onMouseMove);
					document.removeEventListener('mouseup', onMouseUp);
					onDragEnd();
				};

				document.addEventListener('mousemove', onMouseMove, {
					passive: true
				});
				document.addEventListener('mouseup', onMouseUp);
			});

			// --- Touch drag ---
			// Uses a larger threshold so normal tap wobble doesn't register as a drag
			button.addEventListener('touchstart', e => {
				if (e.touches.length !== 1) return;
				const t = e.touches[0];
				onDragStart(t.clientX, t.clientY);
			}, {
				passive: true
			});

			button.addEventListener('touchmove', e => {
				if (e.touches.length !== 1) return;
				const t = e.touches[0];
				onDragMove(t.clientX, t.clientY, TOUCH_DRAG_THRESHOLD);
				if (isDragging) e.preventDefault();
			}, {
				passive: false
			});

			button.addEventListener('touchend', e => {
				const wasDragged = this._wasDragged;
				onDragEnd();
				if (!wasDragged) {
					e.preventDefault(); // suppress the synthetic click that fires ~300ms later
					this._togglePanel();
				}
			}, {
				passive: false
			});

			window.addEventListener('resize', Utils.debounce(() => {
				const btn = document.getElementById('menuToggle');
				if (btn) this._applySavedPosition(btn, true);
			}, 300));

			return button;
		}

		_createActionButton(text, clickHandler) {
			const button = Utils.createElement('button', {
				type: 'button',
				className: 'phpro-tintable-btn',
				textContent: text,
				style: {
					marginBottom: '10px',
					padding: '8px 12px',
					backgroundColor: this._restingButtonBg(),
					color: 'white',
					border: '1px solid white',
					borderRadius: '10px',
					cursor: 'pointer',
					transition: 'all 0.3s',
					width: '100%',
					fontSize: '13px',
				},
			});

			this._attachHoverEffects(button);

			button.addEventListener('click', () => {
				button.style.backgroundColor = 'orange';
				setTimeout(() => {
					button.style.backgroundColor = this._restingButtonBg();
				}, CONFIG.TIMING.BUTTON_FLASH_MS);
				clickHandler();
			});

			return button;
		}

		// Creates the Start/Stop Autoscroll button. Label and colour are updated
		// by _onAutoscrollStateChanged when the running state changes.
		_createAutoscrollButton() {
			const button = Utils.createElement('button', {
				type: 'button',
				id: 'autoscrollButton',
				className: 'phpro-tintable-btn',
				textContent: 'Start Autoscroll',
				style: {
					marginBottom: '15px',
					padding: '8px 12px',
					backgroundColor: this._restingButtonBg(),
					color: 'white',
					border: '1px solid white',
					borderRadius: '10px',
					cursor: 'pointer',
					transition: 'all 0.3s',
					width: '100%',
				},
			});

			this._attachHoverEffects(button);
			button.addEventListener('click', () => this._autoScroller.toggle());
			return button;
		}

		_createScrollToTopButton() {
			const button = this._createActionButton(
				'Scroll to top of the page',
				() => this._scrollToTop.scrollToTop()
			);
			button.id = 'scrolltotopButton';
			// This button uses 15px bottom margin (vs the default 10px) to match the
			// autoscroll button it sits beneath.
			button.style.marginBottom = '15px';
			return button;
		}

		// Called when a toggle is clicked. Updates state, animates the track/thumb,
		// then calls the feature handler via setTimeout so the UI paint happens first.
		_handleToggleClick(feature, track, thumb) {
			try {
				const newState = this._state.toggle(feature.key);
				track.style.backgroundColor = newState ? 'orange' : '#666';
				track.setAttribute('aria-checked', String(newState));
				thumb.style.left = newState ? '22px' : '2px';
				setTimeout(() => feature.handler(), 0);
			} catch (err) {
				handleError(`MenuManager._handleToggleClick("${feature.key}")`, err);
			}
		}

		_onAutoscrollStateChanged({
			isRunning
		}) {
			const button = document.getElementById('autoscrollButton');
			if (!button) return;
			if (isRunning) {
				button.textContent = 'Stop Autoscroll';
				button.classList.add('phpro-autoscroll-running');
			} else {
				button.textContent = 'Start Autoscroll';
				button.classList.remove('phpro-autoscroll-running');
				// Reset inline styles in case _attachHoverEffects left them.
				// Use the tint helper so the resting colour matches the transparency state.
				button.style.backgroundColor = this._restingButtonBg();
				button.style.borderColor = 'white';
				button.style.color = 'white';
			}
		}

		// Toggles the panel open/closed. `force` true/false overrides the current
		// state (used by the dismiss handler to always close).
		_togglePanel(force) {
			const willOpen = force !== undefined ? force : this._menu.style.display === 'none';
			willOpen ? this._show() : this._hide();
		}

		_scheduleButtonFade() {
			if (!this._state.get('swipeToOpenState')) return;
			this._cancelButtonFade();
			this._fadeTimer = setTimeout(() => {
				if (this._panelOpen || !this._toggleButton) return;
				this._toggleButton.style.opacity = '0.15';
			}, CONFIG.TIMING.BUTTON_FADE_DELAY_MS);
		}

		// Cancels any pending fade timer and restores the button to full opacity.
		_cancelButtonFade() {
			if (this._fadeTimer !== null) {
				clearTimeout(this._fadeTimer);
				this._fadeTimer = null;
			}
			if (this._toggleButton) {
				this._toggleButton.style.opacity = '1';
			}
		}

		_show() {
			if (!this._menu) return;
			const onRight = this._state.get('menuOnRightState');
			const offScreen = onRight ? 'translateX(110%)' : 'translateX(-110%)';

			// Place off-screen, make visible, then animate in. Two rAF calls make the
			// browser paint the off-screen position first so the transition isn't skipped.
			this._menu.style.transition = 'none';
			this._menu.style.transform = offScreen;
			this._menu.style.display = 'block';
			requestAnimationFrame(() => requestAnimationFrame(() => {
				this._menu.style.transition = `transform ${CONFIG.TIMING.SLIDE_MS}ms cubic-bezier(0.25, 0.1, 0.25, 1)`;
				this._menu.style.transform = 'translateX(0)';
			}));

			this._panelOpen = true;
			this._cancelButtonFade();
		}

		_hide() {
			if (!this._menu) return;
			const onRight = this._state.get('menuOnRightState');
			const offScreen = onRight ? 'translateX(110%)' : 'translateX(-110%)';

			this._menu.style.transition = `transform ${CONFIG.TIMING.SLIDE_MS}ms cubic-bezier(0.25, 0.1, 0.25, 1)`;
			this._menu.style.transform = offScreen;

			// Hide after the slide-out transition completes. Guard with _panelOpen
			// so a rapid re-open before the timer fires doesn't hide the menu again.
			setTimeout(() => {
				if (!this._panelOpen) this._menu.style.display = 'none';
			}, CONFIG.TIMING.SLIDE_MS + 20);

			this._panelOpen = false;
			this._swipeOpenedFromSide = null;
			this._scheduleButtonFade();
			this._collapseQuickMenuChooser();
		}

		_applyMenuSide() {
			if (!this._menu) return;
			const onRight = this._state.get('menuOnRightState');
			this._menu.style.left = onRight ? 'auto' : '5px';
			this._menu.style.right = onRight ? '5px' : 'auto';
		}

		_toggleVisibility() {
			this._togglePanel();
		}

		// Closes the panel when the user clicks or taps outside it.
		// Listens on both mousedown (desktop) and touchstart (mobile).
		_setupPanelDismiss() {
			const handler = e => {
				if (!this._panelOpen) return;
				const clickedInsideMenu = this._menu.contains(e.target);
				const clickedToggleButton = e.target === this._toggleButton ||
					this._toggleButton.contains(e.target);
				if (!clickedInsideMenu && !clickedToggleButton) {
					this._togglePanel(false);
				}
			};
			document.addEventListener('mousedown', handler);
			document.addEventListener('touchstart', handler, {
				passive: true
			});
			// Escape closes the panel - standard dialog behaviour.
			document.addEventListener('keydown', e => {
				if (e.key === 'Escape' && this._panelOpen) {
					this._togglePanel(false);
				}
			});
		}

		_setupTouchHandlers() {
			const TOUCH = CONFIG.TIMING.TOUCH;
			let startX = 0;
			let startY = 0;

			// ── Edge swipe to open ─────────────────────────────────────────────────
			document.addEventListener('touchstart', e => {
				startX = e.touches[0].clientX;
				startY = e.touches[0].clientY;
			}, { passive: true });

			document.addEventListener('touchend', e => {
				if (!this._state.get('swipeToOpenState')) return;
				if (this._panelOpen) return;
				if (this._menu.contains(e.target)) return;

				const dx = e.changedTouches[0].clientX - startX;
				const dy = e.changedTouches[0].clientY - startY;

				if (Math.abs(dx) < TOUCH.SWIPE_MIN_PX) return;
				if (Math.abs(dy) > Math.abs(dx)) return;

				const W = window.innerWidth;
				const onRight = this._state.get('menuOnRightState');

				// Only the edge matching the menu's side triggers an open gesture.
				const fromLeft  = !onRight && startX <= TOUCH.EDGE_THRESHOLD_PX && dx > 0;
				const fromRight =  onRight && startX >= W - TOUCH.EDGE_THRESHOLD_PX && dx < 0;

				if (fromLeft) {
					this._swipeOpenedFromSide = 'left';
					this._show();
				} else if (fromRight) {
					this._swipeOpenedFromSide = 'right';
					this._show();
				}
			}, { passive: true });

			// ── Swipe on menu to close ─────────────────────────────────────────────
			this._menu.addEventListener('touchstart', e => {
				startX = e.touches[0].clientX;
				startY = e.touches[0].clientY;
			}, { passive: true });

			this._menu.addEventListener('touchend', e => {
				const dx = e.changedTouches[0].clientX - startX;
				const dy = e.changedTouches[0].clientY - startY;

				if (Math.abs(dx) < TOUCH.SWIPE_MIN_PX) return;
				if (Math.abs(dy) > Math.abs(dx)) return;

				// Swipe back toward the edge the menu came from.
				// If opened via button (null), accept whichever direction matches the side.
				const onRight = this._state.get('menuOnRightState');
				const side = this._swipeOpenedFromSide ?? (onRight ? 'right' : 'left');
				const shouldClose = (side === 'left' && dx < 0) || (side === 'right' && dx > 0);

				if (shouldClose) this._hide();
			}, { passive: true });
		}

		// Re-reads every toggle straight from CrossDomainStorage, bypassing the
		// StateManager cache, so another tab's writes are always picked up.
		updateToggleStates() {
			try {
				for (const feature of this._features) {
					const track = document.getElementById(feature.id);
					if (!track) continue;
					// Read straight from storage - skips the in-memory cache so another
					// tab's writes are picked up immediately on visibility restore.
					const raw = CrossDomainStorage.getItem(feature.key);
					const isActive = raw !== null ? raw === 'true' : feature.defaultState;
					const thumb = track.querySelector('div');
					track.style.backgroundColor = isActive ? 'orange' : '#666';
					track.setAttribute('aria-checked', String(isActive));
					if (thumb) thumb.style.left = isActive ? '22px' : '2px';
				}
				// Sync the sort dropdown(s) - the quick menu can hold a second
				// copy, so both ids are checked.
				for (const selectId of ['phpro-sort-select', 'phpro-qm-sort-select']) {
					const select = document.getElementById(selectId);
					if (select) select.value = Utils.getValidSortMode();
				}
				// Sync section collapse states - picks up changes made in another tab.
				for (const section of this._sections) {
					const isCollapsed = CrossDomainStorage.getItem(section.storageKey) === 'true';
					section.content.style.display = isCollapsed ? 'none' : 'block';
					section.arrow.textContent = isCollapsed ? '▸' : '▾';
					if (section.header) section.header.setAttribute('aria-expanded', String(!isCollapsed));
				}
				// Sync filter tag chips - re-render every filter type from storage.
				for (const type of Object.keys(MenuManager._FILTER_TYPES)) {
					this._renderFilterTags(type);
				}
				// Sync the duration slider from storage (another tab may have
				// changed it while this tab was in the background).
				this._durationSliderRefs?.resync();
				// Rebuild the quick menu in case another tab changed the enabled
				// toggle or the item selection while this tab was backgrounded.
				this._renderQuickMenu();
			} catch (err) {
				handleError('MenuManager.updateToggleStates', err);
			}
		}

		// Removes the injected stylesheet and the floating quick-menu panel, if
		// present. Called by App._cleanup().
		cleanup() {
			this._styleSheet?.remove();
			this._quickMenuPanel?.remove();
			clearTimeout(this._quickMenuFadeTimer);
		}

		_attachHoverEffects(button) {
			button.addEventListener('mouseenter', () => {
				if (!button.classList.contains('phpro-autoscroll-running')) {
					button.style.color = 'orange';
					button.style.borderColor = 'orange';
				}
			});
			button.addEventListener('mouseleave', () => {
				if (!button.classList.contains('phpro-autoscroll-running')) {
					button.style.color = 'white';
					button.style.borderColor = 'white';
				}
			});
		}
	}

	// Top-level controller. Wires the feature classes together and runs the
	// MutationObserver that watches for items added by infinite scroll.
	class App {
		constructor() {
			this._eventEmitter = new EventEmitter();
			this._state = new StateManager(this._eventEmitter);
			this._autoScroller = new AutoScroller(this._eventEmitter);
			this._videoSorter = new VideoSorter(this._state);
			this._videoHider = new VideoHider(this._state, this._videoSorter);
			this._languageManager = new LanguageManager(this._state);
			this._durationFilter = new DurationFilter(this._state);
			this._playlistManager = new PlaylistManager();
			this._scrollToTop = new ScrollToTop();
			this._menu = new MenuManager(
				this._state, this._eventEmitter, this._autoScroller, this._scrollToTop
			);

			this._observer = null;
			this._observedTargets = new Set();
			this._lastLiCount = 0;

			this._debouncedInit = Utils.debounce(
				this._initializeFeatures.bind(this),
				CONFIG.TIMING.MUTATION_DEBOUNCE_MS
			);

			this._setupStateValidators();
			this._setupEventHandlers();
		}

		_setupStateValidators() {
			const featureBoolKeys = this._menu._features.map(f => f.key);
			const extraBoolKeys = ['menuButtonRightSideState'];

			for (const key of [...featureBoolKeys, ...extraBoolKeys]) {
				this._state.addValidator(key, v => typeof v === 'boolean');
			}
		}

		// Wires up all EventEmitter listeners - the central event routing table.
		_setupEventHandlers() {
			this._eventEmitter.on('sortByAward', data => this._videoSorter.sortByAward(data === true));
			this._eventEmitter.on('sortByDuration', data => this._videoSorter.sortByDuration(data === true));
			this._eventEmitter.on('sortByViews', data => this._videoSorter.sortByViews(data === true));
			this._eventEmitter.on('hideVideos', () => this._videoHider.hideVideos());
			this._eventEmitter.on('redirectToEnglish', () => this._languageManager.redirectToEnglish());
			this._eventEmitter.on('enforceSearchDuration', () => this._durationFilter.enforceListingUrl());
			this._eventEmitter.on('toggleCursorHide', () => VideoPlayer.toggleCursorHide(this._state.get('cursorHideState')));

			this._eventEmitter.on('stateChanged', ({
				key,
				newValue
			}) => {
				Utils.log(`State changed: ${key} = ${newValue}`);
			});

			this._eventEmitter.on('autoscrollStateChanged', ({
				isRunning
			}) => {
				if (isRunning) {
					this._observer?.disconnect();
					Utils.log('App: autoscroll started, observer paused');
				} else {
					Utils.log('App: autoscroll stopped, running features & resuming observer');
					this._initializeFeatures();
					this._setupObserver();
					// Delay the scroll so it fires after DOM mutations from
					// _initializeFeatures / _setupObserver have fully settled.
					setTimeout(() => this._scrollToTop.scrollToTop(), CONFIG.TIMING.MUTATION_DEBOUNCE_MS + 50);
				}
			});
		}

		// Main initialisation - called once the DOM is ready.
		async init() {
			try {
				Utils.log('App: initializing');

				this._durationFilter.enforceListingUrl();

				ElementHider.hideElements();
				this._languageManager.redirectToEnglish();
				VideoPlayer.toggleCursorHide(this._state.get('cursorHideState', true));

				this._playlistManager.init();
				this._menu.create();

				AgeGate.reloadIfNeeded();

				// Kick off download button if already enabled (e.g. on video pages).
				// Passes the eventEmitter explicitly instead of leaking it via stateManager.
				DownloadManager.init(this._state, this._eventEmitter);

				setTimeout(() => this._initializeFeatures(), CONFIG.TIMING.FEATURE_INIT_DELAY_MS);

				this._setupObserver();
				this._setupWindowListeners();

				Utils.log('App: initialized successfully');
			} catch (err) {
				handleError('App.init', err);
			}
		}

		_initializeFeatures() {
			try {
				const sortMode = Utils.getValidSortMode();
				if (sortMode === 'duration') this._videoSorter.sortByDuration();
				else if (sortMode === 'award') this._videoSorter.sortByAward();
				else if (sortMode === 'views') this._videoSorter.sortByViews();

				if (
					this._state.get('hideWatchedState') ||
					this._state.get('hidePaidContentState') ||
					this._state.get('hideVRState') ||
					this._state.get('hideShortsState') ||
					this._state.get('hideDurationOutOfRangeState')
				) {
					this._videoHider.hideVideos();
				}
				if (this._state.get('muteState')) VideoPlayer.mute();
				Utils.log('App: features initialized');
			} catch (err) {
				handleError('App._initializeFeatures', err);
			}
		}

		// Returns all ul.videos elements currently in the DOM.
		_getScopeTargets() {
			return Utils.safeQuerySelectorAll('ul.videos');
		}

		// Counts total <li> items across a set of root elements.
		// Used to detect when the site has added or removed video items.
		_countLisIn(roots) {
			return roots.reduce((sum, root) => sum + root.querySelectorAll('li').length, 0);
		}

		// Sets up (or re-scopes) the MutationObserver. Prefers specific video
		// list containers for efficiency; falls back to document.body if none exist.
		_setupObserver() {
			try {
				this._observer?.disconnect();
				this._observedTargets.clear();

				const scopeTargets = this._getScopeTargets();
				const observeBody = scopeTargets.length === 0;

				const roots = observeBody ? [document.body] : scopeTargets;
				for (const el of roots) this._observedTargets.add(el);

				this._lastLiCount = this._countLisIn(roots);

				const observeOptions = {
					childList: true,
					subtree: true,
					attributes: false,
					characterData: false,
				};

				this._observer = new MutationObserver(
					Utils.throttle(
						mutations => this._onMutations(mutations, observeBody),
						CONFIG.TIMING.OBSERVER_THROTTLE_MS
					)
				);

				for (const root of roots) {
					this._observer.observe(root, observeOptions);
				}

				Utils.log(
					observeBody ?
					'App: observer watching document.body (fallback - no containers found yet)' :
					`App: observer scoped to ${roots.length} container(s)`
				);
			} catch (err) {
				handleError('App._setupObserver', err);
			}
		}

		_onMutations(mutations, watchingBody) {
			try {
				const addedNodes = mutations.flatMap(m =>
					Array.from(m.addedNodes).filter(n => n.nodeType === Node.ELEMENT_NODE)
				);

				const newTargets = this._getScopeTargets().filter(
					el => !this._observedTargets.has(el)
				);

				if (newTargets.length > 0) {
					Utils.log(`App: ${newTargets.length} new scopeable container(s) found - re-scoping observer`);
					this._setupObserver();
					this._debouncedInit();
					return;
				}

				const observedRoots = Array.from(this._observedTargets);
				const currentLiCount = this._countLisIn(observedRoots);

				if (currentLiCount !== this._lastLiCount) {
					Utils.log(`App: li count changed ${this._lastLiCount} → ${currentLiCount}`);
					this._lastLiCount = currentLiCount;

					if (
						addedNodes.length > 0 &&
						(
							this._state.get('hideWatchedState') ||
							this._state.get('hidePaidContentState') ||
							this._state.get('hideVRState') ||
							this._state.get('hideDurationOutOfRangeState')
						)
					) {
						this._videoHider.hideVideos(addedNodes);
					}

					this._debouncedInit();
				}
			} catch (err) {
				handleError('App._onMutations', err);
			}
		}

		// Registers window-level listeners needed for the lifetime of the page.
		_setupWindowListeners() {
			document.addEventListener('visibilitychange', () => {
				if (document.visibilityState === 'visible') {
					Utils.log('App: tab visible - syncing state');
					try {
						this._state.clearCache();
						this._menu.updateToggleStates();
						// Applied immediately as well as via the delayed _initializeFeatures() below,
						// so returning to a tab picks up a volume changed elsewhere right away.
						if (this._state.get('syncVolumeState')) {
							VideoPlayer.applySavedVolume();
						}

						const menuButton = document.getElementById('menuToggle');
						if (menuButton) {
							this._menu._applySavedPosition(menuButton, true);
						}

						setTimeout(() => this._initializeFeatures(), CONFIG.TIMING.FEATURE_INIT_DELAY_MS);
					} catch (err) {
						handleError('App.visibilitychange', err);
					}
				} else {
					VideoPlayer.resetMuteState();
				}
			});

			window.addEventListener('load', () => {
				setTimeout(() => ElementHider.hideElements(), CONFIG.TIMING.ELEMENT_HIDE_LOAD_DELAY_MS);
			});

			// Another tab changed a setting. Cookie writes fire no event, so setItem()
			// touches a localStorage ping key to wake this up. 'storage' is same-origin
			// only, so cross-subdomain still relies on the visibility sync above.
			window.addEventListener('storage', event => {
				if (event.key !== CrossDomainStorage.SYNC_PING_KEY) return;
				try {
					Utils.log('App: another tab changed a setting, resyncing');
					this._state.clearCache();
					this._menu.updateToggleStates();
					this._initializeFeatures();
				} catch (err) {
					handleError('App.storage', err);
				}
			});

			// Teardown on 'pagehide', and only when the page is really being destroyed.
			// When persisted is true the page is entering bfcache and will be restored
			// alive. The old beforeunload handler gutted it unconditionally, which is
			// what left a restored tab overwriting newer settings from a frozen cache.
			window.addEventListener('pagehide', event => {
				if (!event.persisted) this._cleanup();
			});

			// Restored from bfcache. No script re-ran and DOMContentLoaded will not fire
			// again, so the cache and the DOM have to be resynced by hand.
			window.addEventListener('pageshow', event => {
				if (!event.persisted) return;
				try {
					Utils.log('App: restored from bfcache, resyncing');
					this._state.clearCache();
					this._menu.updateToggleStates();
					this._setupObserver();
					this._initializeFeatures();
				} catch (err) {
					handleError('App.pageshow', err);
				}
			});

			window.addEventListener('error', event => {
				if (event.filename?.includes('Pornhub Pro-ish')) {
					handleError('window.onerror', new Error(event.message));
				}
			});
		}

		// Tears down observers, autoscroll, menu and listeners. Not called when the
		// page enters bfcache.
		_cleanup() {
			try {
				this._observer?.disconnect();
				this._observer = null;
				this._observedTargets.clear();
				if (this._autoScroller.isRunning) this._autoScroller.stop();
				this._menu.cleanup();
				this._eventEmitter.removeAllListeners();
				Utils.log('App: cleanup complete');
			} catch (err) {
				handleError('App._cleanup', err);
			}
		}
	}

	// Entry point.
	function initializeApp() {
		try {
			const app = new App();
			if (document.readyState === 'loading') {
				document.addEventListener('DOMContentLoaded', () => app.init());
			} else {
				app.init();
			}
		} catch (err) {
			console.error(`${CONFIG.SCRIPT_NAME}: fatal error during startup:`, err);
		}
	}

	initializeApp();
})();