Pornhub Pro-ish

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();