Sleazy Fork is available in English.

Pornhub Pro-ish

Alters and improves the PH experience with better performance and code structure

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Pornhub Pro-ish
// @namespace    https://www.reddit.com/user/Alpacinator
// @version      6.5.0
// @include      *://*.pornhub.com/*
// @grant        none
// @description  Alters and improves the PH experience with better performance and code structure
// ==/UserScript==

(function() {
    'use strict';

    // ---------------------------------------------------------------------------
    // Configuration
    // ---------------------------------------------------------------------------

    const CONFIG = {
        SCRIPT_NAME: 'Pornhub Pro-ish',
        TIMING: {
            MUTATION_DEBOUNCE_MS: 300,
            LANGUAGE_CHECK_DELAY_MS: 1000,
            // Used as a CSS animation duration (seconds, not ms — named to reflect this)
            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,
            ELEMENT_HIDE_LOAD_DELAY_MS: 500,
            FILTER_WORDS_MAX_LENGTH: 255,
        },
        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',
            MUTE_BUTTON: 'div.mgp_volume[data-text="Mute"]',
            LANGUAGE_DROPDOWN: 'li.languageDropdown',
            ENGLISH_OPTION: 'li[data-lang="en"] a.networkTab',
            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"]',
            ],
        },
    };

    // ---------------------------------------------------------------------------
    // Unified error handling
    // ---------------------------------------------------------------------------

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

    // ---------------------------------------------------------------------------
    // Utilities
    // ---------------------------------------------------------------------------

    const Utils = {
        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);
        },

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

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

        safeQuerySelector(selector, context = document) {
            try {
                return context.querySelector(selector);
            } catch (err) {
                handleError(`safeQuerySelector("${selector}")`, err, 'warn');
                return null;
            }
        },

        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.TIMING.FILTER_WORDS_MAX_LENGTH);
            return clamped
                .split(',')
                .map(w => w.trim().toLowerCase())
                .filter(w => w.length >= 1);
        },

        addStylesheet(css) {
            const style = Utils.createElement('style', {
                textContent: css
            });
            document.head.appendChild(style);
            return style;
        },
    };

    // ---------------------------------------------------------------------------
    // EventEmitter
    // ---------------------------------------------------------------------------

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

    // ---------------------------------------------------------------------------
    // StateManager
    // ---------------------------------------------------------------------------

    class StateManager {
        constructor(eventEmitter) {
            this._cache = new Map();
            this._eventEmitter = eventEmitter;
            this._validators = new Map();
        }

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

        get(key, defaultValue = false) {
            if (this._cache.has(key)) return this._cache.get(key);

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

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

        toggle(key) {
            const newValue = !this.get(key);
            this.set(key, newValue);
            return newValue;
        }

        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) {
            localStorage.setItem(key, String(value));
        }
    }

    // ---------------------------------------------------------------------------
    // Feature descriptor
    // ---------------------------------------------------------------------------

    class Feature {
        constructor({
            label,
            key,
            handler,
            id,
            defaultState = false,
            category = 'general',
            description = ''
        }) {
            this.label = label;
            this.key = key;
            this.handler = handler;
            this.id = id;
            this.defaultState = defaultState;
            this.category = category;
            this.description = description;
        }
    }

    // ---------------------------------------------------------------------------
    // AutoScroller
    // ---------------------------------------------------------------------------

    class AutoScroller {
        constructor(eventEmitter) {
            this._eventEmitter = eventEmitter;
            this.isRunning = false;
            this._timeoutId = null;
            this._playlistPage = null;
            this._fetchedPages = null;
        }

        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;
            Utils.log('AutoScroll started');
            this._eventEmitter.emit('autoscrollStateChanged', {
                isRunning: true
            });
            this._scheduleNext(0);
            return true;
        }

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

        toggle() {
            return this.isRunning ? this.stop() : this.start();
        }

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

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

                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;
                for (const li of incoming) {
                    if (!existingIds.has(li.dataset.videoId)) list.appendChild(li);
                }
                const countAfter = list.querySelectorAll('li.pcVideoListItem').length;

                incoming[0]?.scrollIntoView({
                    behavior: 'smooth',
                    block: 'start'
                });
                this._playlistPage++;

                if (countAfter <= countBefore) {
                    this._consecutiveEmpty++;
                    Utils.log(`AutoScroll: no new items (${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;
                }
            } 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)));
        }
    }

    // ---------------------------------------------------------------------------
    // VideoSorter
    // ---------------------------------------------------------------------------

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

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

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

    // ---------------------------------------------------------------------------
    // VideoHider
    // ---------------------------------------------------------------------------

    class VideoHider {
        constructor(stateManager, videoSorter) {
            this._state = stateManager;
            this._videoSorter = videoSorter;
            this._cachedFilterWords = null;
            this._lastFilterString = null;
        }

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

        /**
         * Hide videos across the page.
         *
         * Also hides/shows the entire shorts section based on the hideShorts toggle.
         *
         * @param {Element[]|null} [addedNodes=null]
         */
        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 filterWords = this.getFilterWords();

            // --- Shorts section: hide/show the entire container ---
            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,
                            filterWords
                        }) ?
                        'none' :
                        '';
                } catch (err) {
                    handleError('VideoHider.hideVideos (item)', err, 'warn');
                }
            }
        }

        _shouldHide(item, {
            hideWatched,
            hidePaid,
            hideVR,
            filterWords
        }) {
            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 (filterWords.length > 0) {
                const text = item.textContent.toLowerCase();
                if (filterWords.some(w => text.includes(w))) return true;
            }
            return false;
        }
    }

    // ---------------------------------------------------------------------------
    // VideoPlayer
    // ---------------------------------------------------------------------------

    class VideoPlayer {
        static _hasMuted = false;

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

            const buttons = Utils.safeQuerySelectorAll(CONFIG.SELECTORS.MUTE_BUTTON);
            for (const button of buttons) {
                try {
                    for (const type of ['mouseover', 'focus', 'mousedown', 'mouseup', 'click']) {
                        button.dispatchEvent(new Event(type, {
                            bubbles: true,
                            cancelable: true
                        }));
                    }
                } catch (err) {
                    handleError('VideoPlayer.mute (button)', err, 'warn');
                }
            }

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

        static resetMuteState() {
            VideoPlayer._hasMuted = false;
        }

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

    // ---------------------------------------------------------------------------
    // LanguageManager
    // ---------------------------------------------------------------------------

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

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

            setTimeout(() => {
                try {
                    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);
        }
    }

    // ---------------------------------------------------------------------------
    // ElementHider
    // ---------------------------------------------------------------------------

    class ElementHider {
        static 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');
                }
            }
        }
    }

    // ---------------------------------------------------------------------------
    // PlaylistManager
    // ---------------------------------------------------------------------------

    class PlaylistManager {
        init() {
            document.addEventListener('click', event => {
                if (event.target?.matches('button[onclick="deleteFromPlaylist(this);"]')) {
                    this._addRedOverlay(event.target);
                }
            });
        }

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

    // ---------------------------------------------------------------------------
    // ScrollToTop
    // ---------------------------------------------------------------------------

    class ScrollToTop {
        scrollToTop() {
            window.scrollTo({
                top: 0,
                behavior: 'smooth'
            });
        }
    }

    // ---------------------------------------------------------------------------
    // MenuManager
    // ---------------------------------------------------------------------------

    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._filterInput = null;
            this._styleSheet = null;

            this._features = this._buildFeatureDefinitions();

            this._eventEmitter.on('autoscrollStateChanged', this._onAutoscrollStateChanged.bind(this));
        }

        create() {
            Utils.log('MenuManager: creating menu');
            try {
                this._styleSheet = this._addMenuStyles();
                this._menu = this._createMenuContainer();
                this._addFeatureToggles();
                this._addManualButtons();
                this._addFilterSection();
                document.documentElement.appendChild(this._menu);

                this._toggleButton = this._createToggleButton();
                document.documentElement.appendChild(this._toggleButton);

                this._setupPanelDismiss();
            } 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;
                }
            `);
        }

        _createMenuContainer() {
            return Utils.createElement('div', {
                id: 'sideMenu',
                style: {
                    position: 'fixed',
                    top: '40px',
                    left: '5px',
                    padding: '15px',
                    maxHeight: '80vh',
                    width: 'min-content',
                    minWidth: '220px',
                    backgroundColor: 'rgba(0,0,0,0.92)',
                    zIndex: '9999',
                    display: 'none',
                    flexDirection: 'column',
                    borderRadius: '10px',
                    border: '1px solid orange',
                    boxSizing: 'border-box',
                    overflowY: 'auto',
                    fontFamily: 'Arial, sans-serif',
                    fontSize: '13px',
                },
            });
        }

        _buildFeatureDefinitions() {
            return [
                new Feature({
                    label: 'Always use English',
                    key: 'redirectToEnglishState',
                    handler: () => this._eventEmitter.emit('redirectToEnglish'),
                    id: 'redirectToEnglishToggle',
                    defaultState: true,
                    category: 'general',
                }),
                new Feature({
                    label: 'Opaque menu button',
                    key: 'opaqueMenuButtonState',
                    handler: () => {
                        const opaque = this._state.get('opaqueMenuButtonState');
                        if (this._toggleButton) {
                            this._toggleButton.style.opacity = opaque ? '0.50' : '1';
                        }
                    },
                    id: 'opaqueMenuButtonToggle',
                    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',
                }),
                new Feature({
                    label: 'Sort videos by duration',
                    key: 'sortByDurationState',
                    handler: () => this._eventEmitter.emit('sortByDuration'),
                    id: 'sortByDurationToggle',
                    defaultState: false,
                    category: 'sorting',
                }),
                new Feature({
                    label: 'Sort videos by 🏆',
                    key: 'sortByTrophyState',
                    handler: () => this._eventEmitter.emit('sortByTrophy'),
                    id: 'sortByTrophyToggle',
                    defaultState: false,
                    category: 'sorting',
                }),
                new Feature({
                    label: 'Hide watched videos',
                    key: 'hideWatchedState',
                    handler: () => this._eventEmitter.emit('hideVideos'),
                    id: 'hideWatchedToggle',
                    defaultState: false,
                    category: 'filtering',
                }),
                new Feature({
                    label: 'Hide paid content',
                    key: 'hidePaidContentState',
                    handler: () => this._eventEmitter.emit('hideVideos'),
                    id: 'hidePaidContentToggle',
                    defaultState: true,
                    category: 'filtering',
                }),
                // --- NEW: VR toggle ---
                new Feature({
                    label: 'Hide VR videos',
                    key: 'hideVRState',
                    handler: () => this._eventEmitter.emit('hideVideos'),
                    id: 'hideVRToggle',
                    defaultState: false,
                    category: 'filtering',
                }),
                // --- NEW: Shorts toggle ---
                new Feature({
                    label: 'Hide Shorts section',
                    key: 'hideShortsState',
                    handler: () => this._eventEmitter.emit('hideVideos'),
                    id: 'hideShortsToggle',
                    defaultState: false,
                    category: 'filtering',
                }),
                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: 'Hide cursor on video',
                    key: 'cursorHideState',
                    handler: () => this._eventEmitter.emit('toggleCursorHide'),
                    id: 'cursorHideToggle',
                    defaultState: true,
                    category: 'player',
                }),
            ];
        }

        _addFeatureToggles() {
            const grouped = new Map();
            for (const feature of this._features) {
                if (!grouped.has(feature.category)) grouped.set(feature.category, []);
                grouped.get(feature.category).push(feature);
            }

            for (const [category, features] of grouped) {
                const header = Utils.createElement('h3', {
                    textContent: category.charAt(0).toUpperCase() + category.slice(1),
                    className: 'phpro-category-header',
                });
                this._menu.appendChild(header);
                for (const feature of features) {
                    this._menu.appendChild(this._createToggleRow(feature));
                }
            }
        }

        _addManualButtons() {
            const container = Utils.createElement('div', {
                style: {
                    marginTop: '20px',
                    width: '100%'
                },
            });

            const manualButtons = [{
                    text: 'Put 🏆 first manually',
                    handler: () => this._eventEmitter.emit('sortByTrophy', true)
                },
                {
                    text: 'Sort by duration manually',
                    handler: () => this._eventEmitter.emit('sortByDuration', true)
                },
            ];

            for (const {
                    text,
                    handler
                }
                of manualButtons) {
                container.appendChild(this._createActionButton(text, handler));
            }

            container.appendChild(this._createAutoscrollButton());
            container.appendChild(this._createScrollToTopButton());
            this._menu.appendChild(container);
        }

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

            const label = Utils.createElement('label', {
                textContent: 'Words to filter out:',
                style: {
                    color: 'white',
                    display: 'block',
                    marginBottom: '5px',
                    fontSize: '14px',
                    width: '100%'
                },
            });

            this._filterInput = Utils.createElement('input', {
                type: 'text',
                id: 'inputFilterWords',
                placeholder: 'Separate with commas',
                maxlength: String(CONFIG.TIMING.FILTER_WORDS_MAX_LENGTH),
                style: {
                    display: 'block',
                    padding: '8px',
                    border: '1px solid #ccc',
                    borderRadius: '5px',
                    fontSize: '14px'
                },
            });

            const saved = localStorage.getItem('savedFilterWords');
            if (saved) this._filterInput.value = saved.slice(0, CONFIG.TIMING.FILTER_WORDS_MAX_LENGTH);

            this._filterInput.addEventListener('input', Utils.debounce(() => {
                const value = this._filterInput.value.slice(0, CONFIG.TIMING.FILTER_WORDS_MAX_LENGTH);
                this._filterInput.value = value;
                localStorage.setItem('savedFilterWords', value);
                this._eventEmitter.emit('hideVideos');
            }, 300));

            container.appendChild(label);
            container.appendChild(this._filterInput);
            this._menu.appendChild(container);
        }
        _createToggleRow(feature) {
            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: feature.id,
                style: {
                    position: 'relative',
                    width: '40px',
                    height: '20px',
                    backgroundColor: isActive ? 'orange' : '#666',
                    borderRadius: '20px',
                    cursor: 'pointer',
                    transition: 'background-color 0.2s',
                    flexShrink: '0',
                },
            });

            const thumb = Utils.createElement('div', {
                style: {
                    position: 'absolute',
                    left: isActive ? '22px' : '2px',
                    top: '2px',
                    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 onClick = () => this._handleToggleClick(feature, track, thumb);
            track.addEventListener('click', onClick);
            labelEl.addEventListener('click', onClick);

            container.appendChild(track);
            container.appendChild(labelEl);
            return container;
        }
        _createToggleButton() {
            const button = Utils.createElement('div', {
                id: 'menuToggle',
                textContent: '☰ Menu',
                style: {
                    position: 'fixed',
                    left: '5px',
                    top: '5px',
                    fontSize: '12px',
                    color: 'orange',
                    cursor: 'pointer',
                    zIndex: '10000',
                    padding: '6px 12px',
                    backgroundColor: 'rgba(0,0,0,0.8)',
                    border: '1px solid orange',
                    borderRadius: '15px',
                    fontWeight: 'bold',
                    fontFamily: 'Arial, sans-serif',
                    userSelect: 'none',
                    opacity: this._state.get('opaqueMenuButtonState', false) ? '0.50' : '1',
                    transition: 'opacity 0.2s, background-color 0.2s',
                },
            });

            button.addEventListener('click', e => {
                e.stopPropagation();
                this._togglePanel();
            });
            button.addEventListener('mouseenter', () => {
                button.style.backgroundColor = 'rgba(255,165,0,0.2)';
                button.style.opacity = '1';
            });
            button.addEventListener('mouseleave', () => {
                button.style.backgroundColor = 'rgba(0,0,0,0.8)';
                button.style.opacity = this._state.get('opaqueMenuButtonState', false) ? '0.50' : '1';
            });
            return button;
        }

        _createActionButton(text, clickHandler) {
            const button = Utils.createElement('button', {
                textContent: text,
                style: {
                    marginBottom: '10px',
                    padding: '8px 12px',
                    backgroundColor: 'black',
                    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 = 'black';
                }, CONFIG.TIMING.BUTTON_FLASH_MS);
                clickHandler();
            });

            return button;
        }

        _createAutoscrollButton() {
            const button = Utils.createElement('button', {
                id: 'autoscrollButton',
                textContent: 'Start Autoscroll',
                style: {
                    marginBottom: '15px',
                    padding: '8px 12px',
                    backgroundColor: 'black',
                    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 = Utils.createElement('button', {
                id: 'scrolltotopButton',
                textContent: 'Scroll to top of the page',
                style: {
                    marginBottom: '15px',
                    padding: '8px 12px',
                    backgroundColor: 'black',
                    color: 'white',
                    border: '1px solid white',
                    borderRadius: '10px',
                    cursor: 'pointer',
                    transition: 'all 0.3s',
                    width: '100%',
                },
            });

            this._attachHoverEffects(button);

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

            return button;
        }

        _handleToggleClick(feature, track, thumb) {
            try {
                const newState = this._state.toggle(feature.key);
                track.style.backgroundColor = newState ? 'orange' : '#666';
                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.style.backgroundColor = 'red';
                button.style.borderColor = 'red';
            } else {
                button.textContent = 'Start Autoscroll';
                button.style.backgroundColor = 'black';
                button.style.borderColor = 'white';
            }
        }

        _togglePanel(force) {
            const willOpen = force !== undefined ? force : this._menu.style.display === 'none';
            willOpen ? this._show() : this._hide();
        }

        _show() {
            if (!this._menu) return;
            this._menu.style.display = 'flex';
            this._panelOpen = true;
        }

        _hide() {
            if (!this._menu) return;
            this._menu.style.display = 'none';
            this._panelOpen = false;
        }

        _toggleVisibility() {
            this._togglePanel();
        }

        _setupPanelDismiss() {
            // Outside click closes panel
            document.addEventListener('mousedown', e => {
                if (!this._panelOpen) return;
                if (!this._menu.contains(e.target) && e.target !== this._toggleButton) {
                    this._togglePanel(false);
                }
            });

            // Distance-based auto-close (mirrors the queue script)
            document.addEventListener('mousemove', e => {
                if (!this._panelOpen) return;
                const rect = this._menu.getBoundingClientRect();
                const dx = Math.max(rect.left - e.clientX, 0, e.clientX - rect.right);
                const dy = Math.max(rect.top - e.clientY, 0, e.clientY - rect.bottom);
                if (Math.sqrt(dx * dx + dy * dy) > 120) this._togglePanel(false);
            });
        }

        updateToggleStates() {
            try {
                for (const feature of this._features) {
                    const track = document.getElementById(feature.id);
                    if (!track) continue;
                    const isActive = this._state.get(feature.key);
                    const thumb = track.querySelector('div');
                    track.style.backgroundColor = isActive ? 'orange' : '#666';
                    if (thumb) thumb.style.left = isActive ? '22px' : '2px';
                }
            } catch (err) {
                handleError('MenuManager.updateToggleStates', err);
            }
        }

        cleanup() {
            this._styleSheet?.remove();
        }

        _attachHoverEffects(button) {
            button.addEventListener('mouseenter', () => {
                if (button.style.backgroundColor !== 'red') {
                    button.style.color = 'orange';
                    button.style.borderColor = 'orange';
                }
            });
            button.addEventListener('mouseleave', () => {
                if (button.style.backgroundColor !== 'red') {
                    button.style.color = 'white';
                    button.style.borderColor = 'white';
                }
            });
        }
    }

    // ---------------------------------------------------------------------------
    // App — main controller
    // ---------------------------------------------------------------------------

    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._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 boolKeys = [
                'sortWithinPlaylistsState', 'sortByTrophyState', 'sortByDurationState',
                'hideWatchedState', 'hidePaidContentState', 'redirectToEnglishState',
                'muteState', 'cursorHideState',
                'hideVRState', 'hideShortsState',
                'opaqueMenuButtonState',
            ];
            for (const key of boolKeys) {
                this._state.addValidator(key, v => typeof v === 'boolean');
            }
        }

        _setupEventHandlers() {
            this._eventEmitter.on('sortByTrophy', data => this._videoSorter.sortByTrophy(data === true));
            this._eventEmitter.on('sortByDuration', data => this._videoSorter.sortByDuration(data === true));
            this._eventEmitter.on('hideVideos', () => this._videoHider.hideVideos());
            this._eventEmitter.on('redirectToEnglish', () => this._languageManager.redirectToEnglish());
            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();
                }
            });
        }

        async init() {
            try {
                Utils.log('App: initializing');

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

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

                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 {
                if (this._state.get('sortByTrophyState')) this._videoSorter.sortByTrophy();
                if (this._state.get('sortByDurationState')) this._videoSorter.sortByDuration();
                if (
                    this._state.get('hideWatchedState') ||
                    this._state.get('hidePaidContentState') ||
                    this._state.get('hideVRState') ||
                    this._state.get('hideShortsState')
                ) {
                    this._videoHider.hideVideos();
                }
                if (this._state.get('muteState')) VideoPlayer.mute();
                Utils.log('App: features initialized');
            } catch (err) {
                handleError('App._initializeFeatures', err);
            }
        }

        _getScopeTargets() {
            return Utils.safeQuerySelectorAll('ul.videos');
        }

        _countLisIn(roots) {
            return roots.reduce((sum, root) => sum + root.querySelectorAll('li').length, 0);
        }

        _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')
                            // Shorts is a section-level hide, not per-li, so no incremental pass needed
                        )
                    ) {
                        this._videoHider.hideVideos(addedNodes);
                    }

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

        _setupWindowListeners() {
            document.addEventListener('visibilitychange', () => {
                if (document.visibilityState === 'visible') {
                    Utils.log('App: tab visible — syncing state');
                    try {
                        this._state.clearCache();
                        this._menu.updateToggleStates();
                        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);
            });

            window.addEventListener('beforeunload', () => {
                this._cleanup();
            });

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

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

    // ---------------------------------------------------------------------------
    // Bootstrap
    // ---------------------------------------------------------------------------

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

})();