Sleazy Fork is available in English.
Alters and improves the PH experience with better performance and code structure
// ==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();
})();